From 02a32bd76d1bb6c3d5527546e1086a4d82aa9f8f Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Sat, 29 Jun 2024 20:01:57 +0900 Subject: [PATCH 01/14] feat: add OIDC login button to login form page Signed-off-by: kumo-rn5s --- web/model/project_pb.d.ts | 60 +++ web/model/project_pb.js | 474 +++++++++++++++++- .../login-page/login-form/index.tsx | 12 + 3 files changed, 544 insertions(+), 2 deletions(-) diff --git a/web/model/project_pb.d.ts b/web/model/project_pb.d.ts index be138e7eef..06f003a1f8 100644 --- a/web/model/project_pb.d.ts +++ b/web/model/project_pb.d.ts @@ -115,6 +115,11 @@ export class ProjectSSOConfig extends jspb.Message { hasGoogle(): boolean; clearGoogle(): ProjectSSOConfig; + getOidc(): ProjectSSOConfig.Oidc | undefined; + setOidc(value?: ProjectSSOConfig.Oidc): ProjectSSOConfig; + hasOidc(): boolean; + clearOidc(): ProjectSSOConfig; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): ProjectSSOConfig.AsObject; static toObject(includeInstance: boolean, msg: ProjectSSOConfig): ProjectSSOConfig.AsObject; @@ -129,6 +134,7 @@ export namespace ProjectSSOConfig { sessionTtl: number, github?: ProjectSSOConfig.GitHub.AsObject, google?: ProjectSSOConfig.Google.AsObject, + oidc?: ProjectSSOConfig.Oidc.AsObject, } export class GitHub extends jspb.Message { @@ -189,9 +195,63 @@ export namespace ProjectSSOConfig { } + export class Oidc extends jspb.Message { + getClientId(): string; + setClientId(value: string): Oidc; + + getClientSecret(): string; + setClientSecret(value: string): Oidc; + + getIssuer(): string; + setIssuer(value: string): Oidc; + + getRedirectUri(): string; + setRedirectUri(value: string): Oidc; + + getAuthorizationEndpoint(): string; + setAuthorizationEndpoint(value: string): Oidc; + + getTokenEndpoint(): string; + setTokenEndpoint(value: string): Oidc; + + getUserInfoEndpoint(): string; + setUserInfoEndpoint(value: string): Oidc; + + getProxyUrl(): string; + setProxyUrl(value: string): Oidc; + + getScopesList(): Array; + setScopesList(value: Array): Oidc; + clearScopesList(): Oidc; + addScopes(value: string, index?: number): Oidc; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): Oidc.AsObject; + static toObject(includeInstance: boolean, msg: Oidc): Oidc.AsObject; + static serializeBinaryToWriter(message: Oidc, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): Oidc; + static deserializeBinaryFromReader(message: Oidc, reader: jspb.BinaryReader): Oidc; + } + + export namespace Oidc { + export type AsObject = { + clientId: string, + clientSecret: string, + issuer: string, + redirectUri: string, + authorizationEndpoint: string, + tokenEndpoint: string, + userInfoEndpoint: string, + proxyUrl: string, + scopesList: Array, + } + } + + export enum Provider { GITHUB = 0, GOOGLE = 2, + OIDC = 3, } } diff --git a/web/model/project_pb.js b/web/model/project_pb.js index 42d79d5061..390346b575 100644 --- a/web/model/project_pb.js +++ b/web/model/project_pb.js @@ -35,6 +35,7 @@ goog.exportSymbol('proto.model.ProjectRBACRole', null, global); goog.exportSymbol('proto.model.ProjectSSOConfig', null, global); goog.exportSymbol('proto.model.ProjectSSOConfig.GitHub', null, global); goog.exportSymbol('proto.model.ProjectSSOConfig.Google', null, global); +goog.exportSymbol('proto.model.ProjectSSOConfig.Oidc', null, global); goog.exportSymbol('proto.model.ProjectSSOConfig.Provider', null, global); goog.exportSymbol('proto.model.ProjectStaticUser', null, global); goog.exportSymbol('proto.model.ProjectUserGroup', null, global); @@ -143,6 +144,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.model.ProjectSSOConfig.Google.displayName = 'proto.model.ProjectSSOConfig.Google'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.model.ProjectSSOConfig.Oidc = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.model.ProjectSSOConfig.Oidc.repeatedFields_, null); +}; +goog.inherits(proto.model.ProjectSSOConfig.Oidc, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.model.ProjectSSOConfig.Oidc.displayName = 'proto.model.ProjectSSOConfig.Oidc'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -1019,7 +1041,8 @@ proto.model.ProjectSSOConfig.toObject = function(includeInstance, msg) { provider: jspb.Message.getFieldWithDefault(msg, 1, 0), sessionTtl: jspb.Message.getFieldWithDefault(msg, 2, 0), github: (f = msg.getGithub()) && proto.model.ProjectSSOConfig.GitHub.toObject(includeInstance, f), - google: (f = msg.getGoogle()) && proto.model.ProjectSSOConfig.Google.toObject(includeInstance, f) + google: (f = msg.getGoogle()) && proto.model.ProjectSSOConfig.Google.toObject(includeInstance, f), + oidc: (f = msg.getOidc()) && proto.model.ProjectSSOConfig.Oidc.toObject(includeInstance, f) }; if (includeInstance) { @@ -1074,6 +1097,11 @@ proto.model.ProjectSSOConfig.deserializeBinaryFromReader = function(msg, reader) reader.readMessage(value,proto.model.ProjectSSOConfig.Google.deserializeBinaryFromReader); msg.setGoogle(value); break; + case 12: + var value = new proto.model.ProjectSSOConfig.Oidc; + reader.readMessage(value,proto.model.ProjectSSOConfig.Oidc.deserializeBinaryFromReader); + msg.setOidc(value); + break; default: reader.skipField(); break; @@ -1133,6 +1161,14 @@ proto.model.ProjectSSOConfig.serializeBinaryToWriter = function(message, writer) proto.model.ProjectSSOConfig.Google.serializeBinaryToWriter ); } + f = message.getOidc(); + if (f != null) { + writer.writeMessage( + 12, + f, + proto.model.ProjectSSOConfig.Oidc.serializeBinaryToWriter + ); + } }; @@ -1141,7 +1177,8 @@ proto.model.ProjectSSOConfig.serializeBinaryToWriter = function(message, writer) */ proto.model.ProjectSSOConfig.Provider = { GITHUB: 0, - GOOGLE: 2 + GOOGLE: 2, + OIDC: 3 }; @@ -1554,6 +1591,402 @@ proto.model.ProjectSSOConfig.Google.prototype.setClientSecret = function(value) }; + +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.model.ProjectSSOConfig.Oidc.repeatedFields_ = [9]; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.toObject = function(opt_includeInstance) { + return proto.model.ProjectSSOConfig.Oidc.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.model.ProjectSSOConfig.Oidc} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.model.ProjectSSOConfig.Oidc.toObject = function(includeInstance, msg) { + var f, obj = { + clientId: jspb.Message.getFieldWithDefault(msg, 1, ""), + clientSecret: jspb.Message.getFieldWithDefault(msg, 2, ""), + issuer: jspb.Message.getFieldWithDefault(msg, 3, ""), + redirectUri: jspb.Message.getFieldWithDefault(msg, 4, ""), + authorizationEndpoint: jspb.Message.getFieldWithDefault(msg, 5, ""), + tokenEndpoint: jspb.Message.getFieldWithDefault(msg, 6, ""), + userInfoEndpoint: jspb.Message.getFieldWithDefault(msg, 7, ""), + proxyUrl: jspb.Message.getFieldWithDefault(msg, 8, ""), + scopesList: (f = jspb.Message.getRepeatedField(msg, 9)) == null ? undefined : f + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.model.ProjectSSOConfig.Oidc} + */ +proto.model.ProjectSSOConfig.Oidc.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.model.ProjectSSOConfig.Oidc; + return proto.model.ProjectSSOConfig.Oidc.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.model.ProjectSSOConfig.Oidc} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.model.ProjectSSOConfig.Oidc} + */ +proto.model.ProjectSSOConfig.Oidc.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setClientId(value); + break; + case 2: + var value = /** @type {string} */ (reader.readString()); + msg.setClientSecret(value); + break; + case 3: + var value = /** @type {string} */ (reader.readString()); + msg.setIssuer(value); + break; + case 4: + var value = /** @type {string} */ (reader.readString()); + msg.setRedirectUri(value); + break; + case 5: + var value = /** @type {string} */ (reader.readString()); + msg.setAuthorizationEndpoint(value); + break; + case 6: + var value = /** @type {string} */ (reader.readString()); + msg.setTokenEndpoint(value); + break; + case 7: + var value = /** @type {string} */ (reader.readString()); + msg.setUserInfoEndpoint(value); + break; + case 8: + var value = /** @type {string} */ (reader.readString()); + msg.setProxyUrl(value); + break; + case 9: + var value = /** @type {string} */ (reader.readString()); + msg.addScopes(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.model.ProjectSSOConfig.Oidc.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.model.ProjectSSOConfig.Oidc} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.model.ProjectSSOConfig.Oidc.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getClientId(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getClientSecret(); + if (f.length > 0) { + writer.writeString( + 2, + f + ); + } + f = message.getIssuer(); + if (f.length > 0) { + writer.writeString( + 3, + f + ); + } + f = message.getRedirectUri(); + if (f.length > 0) { + writer.writeString( + 4, + f + ); + } + f = message.getAuthorizationEndpoint(); + if (f.length > 0) { + writer.writeString( + 5, + f + ); + } + f = message.getTokenEndpoint(); + if (f.length > 0) { + writer.writeString( + 6, + f + ); + } + f = message.getUserInfoEndpoint(); + if (f.length > 0) { + writer.writeString( + 7, + f + ); + } + f = message.getProxyUrl(); + if (f.length > 0) { + writer.writeString( + 8, + f + ); + } + f = message.getScopesList(); + if (f.length > 0) { + writer.writeRepeatedString( + 9, + f + ); + } +}; + + +/** + * optional string client_id = 1; + * @return {string} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.getClientId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.setClientId = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * optional string client_secret = 2; + * @return {string} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.getClientSecret = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.setClientSecret = function(value) { + return jspb.Message.setProto3StringField(this, 2, value); +}; + + +/** + * optional string issuer = 3; + * @return {string} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.getIssuer = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.setIssuer = function(value) { + return jspb.Message.setProto3StringField(this, 3, value); +}; + + +/** + * optional string redirect_uri = 4; + * @return {string} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.getRedirectUri = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.setRedirectUri = function(value) { + return jspb.Message.setProto3StringField(this, 4, value); +}; + + +/** + * optional string authorization_endpoint = 5; + * @return {string} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.getAuthorizationEndpoint = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 5, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.setAuthorizationEndpoint = function(value) { + return jspb.Message.setProto3StringField(this, 5, value); +}; + + +/** + * optional string token_endpoint = 6; + * @return {string} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.getTokenEndpoint = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 6, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.setTokenEndpoint = function(value) { + return jspb.Message.setProto3StringField(this, 6, value); +}; + + +/** + * optional string user_info_endpoint = 7; + * @return {string} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.getUserInfoEndpoint = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 7, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.setUserInfoEndpoint = function(value) { + return jspb.Message.setProto3StringField(this, 7, value); +}; + + +/** + * optional string proxy_url = 8; + * @return {string} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.getProxyUrl = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 8, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.setProxyUrl = function(value) { + return jspb.Message.setProto3StringField(this, 8, value); +}; + + +/** + * repeated string scopes = 9; + * @return {!Array} + */ +proto.model.ProjectSSOConfig.Oidc.prototype.getScopesList = function() { + return /** @type {!Array} */ (jspb.Message.getRepeatedField(this, 9)); +}; + + +/** + * @param {!Array} value + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.setScopesList = function(value) { + return jspb.Message.setField(this, 9, value || []); +}; + + +/** + * @param {string} value + * @param {number=} opt_index + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.addScopes = function(value, opt_index) { + return jspb.Message.addToRepeatedField(this, 9, value, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.model.ProjectSSOConfig.Oidc} returns this + */ +proto.model.ProjectSSOConfig.Oidc.prototype.clearScopesList = function() { + return this.setScopesList([]); +}; + + /** * optional Provider provider = 1; * @return {!proto.model.ProjectSSOConfig.Provider} @@ -1664,6 +2097,43 @@ proto.model.ProjectSSOConfig.prototype.hasGoogle = function() { }; +/** + * optional Oidc oidc = 12; + * @return {?proto.model.ProjectSSOConfig.Oidc} + */ +proto.model.ProjectSSOConfig.prototype.getOidc = function() { + return /** @type{?proto.model.ProjectSSOConfig.Oidc} */ ( + jspb.Message.getWrapperField(this, proto.model.ProjectSSOConfig.Oidc, 12)); +}; + + +/** + * @param {?proto.model.ProjectSSOConfig.Oidc|undefined} value + * @return {!proto.model.ProjectSSOConfig} returns this +*/ +proto.model.ProjectSSOConfig.prototype.setOidc = function(value) { + return jspb.Message.setWrapperField(this, 12, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.model.ProjectSSOConfig} returns this + */ +proto.model.ProjectSSOConfig.prototype.clearOidc = function() { + return this.setOidc(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.model.ProjectSSOConfig.prototype.hasOidc = function() { + return jspb.Message.getField(this, 12) != null; +}; + + diff --git a/web/src/components/login-page/login-form/index.tsx b/web/src/components/login-page/login-form/index.tsx index 12b36d582e..0a5bf8eed7 100644 --- a/web/src/components/login-page/login-form/index.tsx +++ b/web/src/components/login-page/login-form/index.tsx @@ -41,6 +41,9 @@ const useStyles = makeStyles((theme) => ({ githubLoginButton: { background: "#24292E", }, + oidcLoginButton: { + background: "#4A90E2", + }, divider: { display: "flex", alignItems: "center", @@ -94,6 +97,15 @@ export const LoginForm: FC = memo(function LoginForm({ LOGIN WITH GITHUB + +
From 6cdec799b796baf2eadd50200578191304a66ae2 Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Sat, 29 Jun 2024 20:02:22 +0900 Subject: [PATCH 02/14] feat: add OIDC key feature to control plane Signed-off-by: kumo-rn5s --- go.mod | 2 + go.sum | 5 + pkg/app/server/httpapi/callback.go | 54 ++++- pkg/model/project.go | 35 +++ pkg/model/project.pb.go | 362 +++++++++++++++++++++-------- pkg/model/project.pb.validate.go | 165 +++++++++++++ pkg/model/project.proto | 23 ++ pkg/oauth/oidc/oidc.go | 209 +++++++++++++++++ pkg/oauth/oidc/oidc_test.go | 201 ++++++++++++++++ 9 files changed, 949 insertions(+), 107 deletions(-) create mode 100644 pkg/oauth/oidc/oidc.go create mode 100644 pkg/oauth/oidc/oidc_test.go diff --git a/go.mod b/go.mod index 5fd1288407..f04184a96c 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/lambda v1.30.2 github.com/aws/aws-sdk-go-v2/service/s3 v1.31.0 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.0 + github.com/coreos/go-oidc/v3 v3.9.0 github.com/creasty/defaults v1.6.0 github.com/envoyproxy/go-control-plane v0.12.0 github.com/envoyproxy/protoc-gen-validate v1.0.4 @@ -114,6 +115,7 @@ require ( github.com/fatih/color v1.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect + github.com/go-jose/go-jose/v3 v3.0.1 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect diff --git a/go.sum b/go.sum index 1f7519669a..20cb899a7d 100644 --- a/go.sum +++ b/go.sum @@ -190,6 +190,8 @@ github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvA github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo= +github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= @@ -260,6 +262,8 @@ github.com/go-bdd/gobdd v1.1.3-0.20210205100305-4910f932a786/go.mod h1:Q3mXpW/Qm github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= +github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -742,6 +746,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/pkg/app/server/httpapi/callback.go b/pkg/app/server/httpapi/callback.go index 3980598b0c..26b4289d53 100644 --- a/pkg/app/server/httpapi/callback.go +++ b/pkg/app/server/httpapi/callback.go @@ -20,6 +20,7 @@ import ( "encoding/hex" "fmt" "net/http" + "strings" "time" "go.uber.org/zap" @@ -28,25 +29,30 @@ import ( "github.com/pipe-cd/pipecd/pkg/jwt" "github.com/pipe-cd/pipecd/pkg/model" "github.com/pipe-cd/pipecd/pkg/oauth/github" + "github.com/pipe-cd/pipecd/pkg/oauth/oidc" ) func (h *authHandler) handleCallback(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") // Validate request's payload. - projectID := r.FormValue(projectFormKey) - if projectID == "" { - h.handleError(w, r, "Missing project id", nil) + + // split the project ID from the state, if it exists. + // This is necessary because some providers don't support passing the project ID in the query parameters. + state, projectID, err := parseProjectAndState(r) + if err != nil { + h.handleError(w, r, err.Error(), nil) return } - authCode := r.FormValue(authCodeFormKey) - if authCode == "" { - h.handleError(w, r, "Missing auth code", nil) + + if err := checkState(r, h.stateKey, state); err != nil { + h.handleError(w, r, "Unauthorized access", err) return } - if err := checkState(r, h.stateKey); err != nil { - h.handleError(w, r, "Unauthorized access", err) + authCode := r.FormValue(authCodeFormKey) + if authCode == "" { + h.handleError(w, r, "Missing auth code", nil) return } @@ -112,8 +118,7 @@ func (h *authHandler) handleCallback(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, rootPath, http.StatusFound) } -func checkState(r *http.Request, key string) error { - state := r.FormValue(stateFormKey) +func checkState(r *http.Request, key string, state string) error { rawStateToken, err := hex.DecodeString(state) if err != nil { return err @@ -148,8 +153,35 @@ func getUser(ctx context.Context, sso *model.ProjectSSOConfig, project *model.Pr return nil, err } return cli.GetUser(ctx) - + case model.ProjectSSOConfig_OIDC: + if sso.Oidc == nil { + return nil, fmt.Errorf("missing OIDC oauth in the SSO configuration") + } + cli, err := oidc.NewOAuthClient(ctx, sso.Oidc, project, code) + if err != nil { + return nil, err + } + return cli.GetUser(ctx, sso.Oidc.ClientId) default: return nil, fmt.Errorf("not implemented") } } + +func parseProjectAndState(r *http.Request) (string, string, error) { + state := r.FormValue(stateFormKey) + if state == "" { + return "", "", fmt.Errorf("missing state") + } + + // When using OIDC SSO, the state is in the format of "state-token:project-id". + s := strings.Split(state, ":") + if len(s) != 2 { + projectID := r.FormValue(projectFormKey) + if projectID == "" { + return "", "", fmt.Errorf("missing project id") + } + return state, projectID, nil + } else { + return s[0], s[1], nil + } +} diff --git a/pkg/model/project.go b/pkg/model/project.go index b42615aad8..861a8c2790 100644 --- a/pkg/model/project.go +++ b/pkg/model/project.go @@ -15,10 +15,12 @@ package model import ( + "context" "crypto/subtle" "fmt" "net/url" + "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "golang.org/x/oauth2/github" @@ -261,6 +263,11 @@ func (p *ProjectSSOConfig) GenerateAuthCodeURL(project, callbackURL, state strin return "", fmt.Errorf("missing GitHub oauth in the SSO configuration") } return p.Github.GenerateAuthCodeURL(project, callbackURL, state) + case ProjectSSOConfig_OIDC: + if p.Oidc == nil { + return "", fmt.Errorf("missing OIDC oauth in the SSO configuration") + } + return p.Oidc.GenerateAuthCodeURL(project, state) default: return "", fmt.Errorf("not implemented") @@ -348,6 +355,34 @@ func (p *ProjectSSOConfig_GitHub) GenerateAuthCodeURL(project, callbackURL, stat return authURL, nil } +// GenerateAuthCodeURL generates an auth URL for the specified configuration. +func (p *ProjectSSOConfig_Oidc) GenerateAuthCodeURL(project, state string) (string, error) { + ctx := context.Background() + provider, err := oidc.NewProvider(ctx, p.Issuer) + if err != nil { + return "", err + } + + scopes := []string{} + if len(p.Scopes) == 0 { + scopes = append(scopes, oidc.ScopeOpenID) + } else { + scopes = p.Scopes + } + + cfg := oauth2.Config{ + ClientID: p.ClientId, + Endpoint: provider.Endpoint(), + Scopes: scopes, + RedirectURL: p.RedirectUri, + } + + state = fmt.Sprintf("%s:%s", state, project) + authURL := cfg.AuthCodeURL(state, oauth2.ApprovalForce, oauth2.AccessTypeOnline) + + return authURL, nil +} + // HasRBACRole checks whether the RBAC role is exists. func (p *Project) HasRBACRole(name string) bool { for _, v := range p.RbacRoles { diff --git a/pkg/model/project.pb.go b/pkg/model/project.pb.go index ee386699c7..112ad10fa8 100644 --- a/pkg/model/project.pb.go +++ b/pkg/model/project.pb.go @@ -41,6 +41,7 @@ type ProjectSSOConfig_Provider int32 const ( ProjectSSOConfig_GITHUB ProjectSSOConfig_Provider = 0 ProjectSSOConfig_GOOGLE ProjectSSOConfig_Provider = 2 + ProjectSSOConfig_OIDC ProjectSSOConfig_Provider = 3 ) // Enum value maps for ProjectSSOConfig_Provider. @@ -48,10 +49,12 @@ var ( ProjectSSOConfig_Provider_name = map[int32]string{ 0: "GITHUB", 2: "GOOGLE", + 3: "OIDC", } ProjectSSOConfig_Provider_value = map[string]int32{ "GITHUB": 0, "GOOGLE": 2, + "OIDC": 3, } ) @@ -429,6 +432,7 @@ type ProjectSSOConfig struct { SessionTtl int64 `protobuf:"varint,2,opt,name=session_ttl,json=sessionTtl,proto3" json:"session_ttl,omitempty"` Github *ProjectSSOConfig_GitHub `protobuf:"bytes,10,opt,name=github,proto3" json:"github,omitempty"` Google *ProjectSSOConfig_Google `protobuf:"bytes,11,opt,name=google,proto3" json:"google,omitempty"` + Oidc *ProjectSSOConfig_Oidc `protobuf:"bytes,12,opt,name=oidc,proto3" json:"oidc,omitempty"` } func (x *ProjectSSOConfig) Reset() { @@ -491,6 +495,13 @@ func (x *ProjectSSOConfig) GetGoogle() *ProjectSSOConfig_Google { return nil } +func (x *ProjectSSOConfig) GetOidc() *ProjectSSOConfig_Oidc { + if x != nil { + return x.Oidc + } + return nil +} + type ProjectRBACConfig struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -935,6 +946,126 @@ func (x *ProjectSSOConfig_Google) GetClientSecret() string { return "" } +type ProjectSSOConfig_Oidc struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The client id string of OpenID Connect oauth app. + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + // The client secret string of OpenID Connect oauth app. + ClientSecret string `protobuf:"bytes,2,opt,name=client_secret,json=clientSecret,proto3" json:"client_secret,omitempty"` + // The address of OpenID Connect service. + Issuer string `protobuf:"bytes,3,opt,name=issuer,proto3" json:"issuer,omitempty"` + // The address of the redirect uri. + RedirectUri string `protobuf:"bytes,4,opt,name=redirect_uri,json=redirectUri,proto3" json:"redirect_uri,omitempty"` + // The address of the authorization endpoint. + AuthorizationEndpoint string `protobuf:"bytes,5,opt,name=authorization_endpoint,json=authorizationEndpoint,proto3" json:"authorization_endpoint,omitempty"` + // The address of the token endpoint. + TokenEndpoint string `protobuf:"bytes,6,opt,name=token_endpoint,json=tokenEndpoint,proto3" json:"token_endpoint,omitempty"` + // The address of the user info endpoint. + UserInfoEndpoint string `protobuf:"bytes,7,opt,name=user_info_endpoint,json=userInfoEndpoint,proto3" json:"user_info_endpoint,omitempty"` + // The address of the proxy used while communicating with the OpenID Connect service. + ProxyUrl string `protobuf:"bytes,8,opt,name=proxy_url,json=proxyUrl,proto3" json:"proxy_url,omitempty"` + // scopes to request from the OpenID Connect service. + Scopes []string `protobuf:"bytes,9,rep,name=scopes,proto3" json:"scopes,omitempty"` +} + +func (x *ProjectSSOConfig_Oidc) Reset() { + *x = ProjectSSOConfig_Oidc{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_model_project_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProjectSSOConfig_Oidc) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProjectSSOConfig_Oidc) ProtoMessage() {} + +func (x *ProjectSSOConfig_Oidc) ProtoReflect() protoreflect.Message { + mi := &file_pkg_model_project_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProjectSSOConfig_Oidc.ProtoReflect.Descriptor instead. +func (*ProjectSSOConfig_Oidc) Descriptor() ([]byte, []int) { + return file_pkg_model_project_proto_rawDescGZIP(), []int{2, 2} +} + +func (x *ProjectSSOConfig_Oidc) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *ProjectSSOConfig_Oidc) GetClientSecret() string { + if x != nil { + return x.ClientSecret + } + return "" +} + +func (x *ProjectSSOConfig_Oidc) GetIssuer() string { + if x != nil { + return x.Issuer + } + return "" +} + +func (x *ProjectSSOConfig_Oidc) GetRedirectUri() string { + if x != nil { + return x.RedirectUri + } + return "" +} + +func (x *ProjectSSOConfig_Oidc) GetAuthorizationEndpoint() string { + if x != nil { + return x.AuthorizationEndpoint + } + return "" +} + +func (x *ProjectSSOConfig_Oidc) GetTokenEndpoint() string { + if x != nil { + return x.TokenEndpoint + } + return "" +} + +func (x *ProjectSSOConfig_Oidc) GetUserInfoEndpoint() string { + if x != nil { + return x.UserInfoEndpoint + } + return "" +} + +func (x *ProjectSSOConfig_Oidc) GetProxyUrl() string { + if x != nil { + return x.ProxyUrl + } + return "" +} + +func (x *ProjectSSOConfig_Oidc) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + var File_pkg_model_project_proto protoreflect.FileDescriptor var file_pkg_model_project_proto_rawDesc = []byte{ @@ -984,7 +1115,7 @@ var file_pkg_model_project_proto_rawDesc = []byte{ 0x72, 0x02, 0x10, 0x01, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x0d, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x06, 0x52, 0x0c, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x48, 0x61, 0x73, 0x68, 0x22, 0xb2, 0x04, 0x0a, + 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x48, 0x61, 0x73, 0x68, 0x22, 0xc7, 0x07, 0x0a, 0x10, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x53, 0x4f, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x46, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x72, 0x6f, 0x6a, @@ -1000,89 +1131,114 @@ var file_pkg_model_project_proto_rawDesc = []byte{ 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x53, 0x4f, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x47, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x52, 0x06, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x1a, 0xb3, 0x01, 0x0a, 0x06, 0x47, 0x69, 0x74, 0x48, 0x75, 0x62, 0x12, 0x24, - 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x0d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, - 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, - 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, - 0x65, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x62, 0x61, 0x73, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x1d, 0x0a, - 0x0a, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x55, 0x72, 0x6c, 0x12, 0x1b, 0x0a, 0x09, - 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x55, 0x72, 0x6c, 0x1a, 0x5c, 0x0a, 0x06, 0x47, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x12, 0x24, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, - 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x0d, 0x63, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, 0x63, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x22, 0x28, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x47, 0x49, 0x54, 0x48, 0x55, 0x42, 0x10, 0x00, 0x12, - 0x0a, 0x0a, 0x06, 0x47, 0x4f, 0x4f, 0x47, 0x4c, 0x45, 0x10, 0x02, 0x22, 0x04, 0x08, 0x01, 0x10, - 0x01, 0x22, 0x59, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x12, 0x16, 0x0a, 0x06, - 0x65, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x65, 0x64, - 0x69, 0x74, 0x6f, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x69, 0x65, 0x77, 0x65, 0x72, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x76, 0x69, 0x65, 0x77, 0x65, 0x72, 0x22, 0x55, 0x0a, 0x10, - 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x55, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x12, 0x24, 0x0a, 0x09, 0x73, 0x73, 0x6f, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x08, 0x73, 0x73, - 0x6f, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1b, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, 0x72, - 0x6f, 0x6c, 0x65, 0x22, 0x8d, 0x01, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, - 0x42, 0x41, 0x43, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x1b, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x3e, 0x0a, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, - 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, - 0x42, 0x08, 0xfa, 0x42, 0x05, 0x92, 0x01, 0x02, 0x08, 0x01, 0x52, 0x08, 0x70, 0x6f, 0x6c, 0x69, - 0x63, 0x69, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x74, - 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x42, 0x75, 0x69, 0x6c, - 0x74, 0x69, 0x6e, 0x22, 0xff, 0x02, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, - 0x42, 0x41, 0x43, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x27, 0x2e, 0x6d, 0x6f, 0x64, 0x65, - 0x6c, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x52, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x79, - 0x70, 0x65, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x82, 0x01, 0x02, 0x10, 0x01, 0x52, 0x04, 0x74, 0x79, - 0x70, 0x65, 0x12, 0x58, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, - 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, - 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x18, 0xfa, 0x42, 0x09, 0x9a, - 0x01, 0x06, 0x22, 0x04, 0x72, 0x02, 0x10, 0x01, 0xfa, 0x42, 0x09, 0x9a, 0x01, 0x06, 0x2a, 0x04, - 0x72, 0x02, 0x10, 0x01, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x1a, 0x39, 0x0a, 0x0b, - 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x8b, 0x01, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, - 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x41, 0x50, 0x50, 0x4c, 0x49, 0x43, 0x41, 0x54, 0x49, 0x4f, 0x4e, - 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, - 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x10, 0x03, 0x12, 0x09, 0x0a, - 0x05, 0x50, 0x49, 0x50, 0x45, 0x44, 0x10, 0x04, 0x12, 0x14, 0x0a, 0x10, 0x44, 0x45, 0x50, 0x4c, - 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x43, 0x48, 0x41, 0x49, 0x4e, 0x10, 0x05, 0x12, 0x0b, - 0x0a, 0x07, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x10, 0x06, 0x12, 0x0b, 0x0a, 0x07, 0x41, - 0x50, 0x49, 0x5f, 0x4b, 0x45, 0x59, 0x10, 0x07, 0x12, 0x0b, 0x0a, 0x07, 0x49, 0x4e, 0x53, 0x49, - 0x47, 0x48, 0x54, 0x10, 0x08, 0x22, 0xf3, 0x01, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, - 0x74, 0x52, 0x42, 0x41, 0x43, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x12, 0x42, 0x0a, 0x09, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, + 0x67, 0x6c, 0x65, 0x12, 0x30, 0x0a, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, + 0x74, 0x53, 0x53, 0x4f, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4f, 0x69, 0x64, 0x63, 0x52, + 0x04, 0x6f, 0x69, 0x64, 0x63, 0x1a, 0xb3, 0x01, 0x0a, 0x06, 0x47, 0x69, 0x74, 0x48, 0x75, 0x62, + 0x12, 0x24, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x08, 0x63, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x0d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, + 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x75, 0x72, 0x6c, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x62, 0x61, 0x73, 0x65, 0x55, 0x72, 0x6c, 0x12, + 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x55, 0x72, 0x6c, 0x12, 0x1b, + 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x55, 0x72, 0x6c, 0x1a, 0x5c, 0x0a, 0x06, 0x47, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x12, 0x24, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, + 0x01, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x0d, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, 0x63, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x1a, 0xd6, 0x02, 0x0a, 0x04, 0x4f, 0x69, + 0x64, 0x63, 0x12, 0x24, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x08, + 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x0d, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x21, + 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, + 0x69, 0x12, 0x35, 0x0a, 0x16, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x15, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, + 0x2c, 0x0a, 0x12, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x5f, 0x65, 0x6e, 0x64, + 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x75, 0x73, 0x65, + 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x1b, 0x0a, + 0x09, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63, + 0x6f, 0x70, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x6f, 0x70, + 0x65, 0x73, 0x22, 0x32, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, + 0x0a, 0x06, 0x47, 0x49, 0x54, 0x48, 0x55, 0x42, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x47, 0x4f, + 0x4f, 0x47, 0x4c, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4f, 0x49, 0x44, 0x43, 0x10, 0x03, + 0x22, 0x04, 0x08, 0x01, 0x10, 0x01, 0x22, 0x59, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, + 0x74, 0x52, 0x42, 0x41, 0x43, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x14, 0x0a, 0x05, 0x61, + 0x64, 0x6d, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x61, 0x64, 0x6d, 0x69, + 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x65, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x69, 0x65, + 0x77, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x76, 0x69, 0x65, 0x77, 0x65, + 0x72, 0x22, 0x55, 0x0a, 0x10, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x55, 0x73, 0x65, 0x72, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x24, 0x0a, 0x09, 0x73, 0x73, 0x6f, 0x5f, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, + 0x01, 0x52, 0x08, 0x73, 0x73, 0x6f, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1b, 0x0a, 0x04, 0x72, + 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, + 0x10, 0x01, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x8d, 0x01, 0x0a, 0x0f, 0x50, 0x72, 0x6f, + 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x1b, 0x0a, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, + 0x02, 0x10, 0x01, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x3e, 0x0a, 0x08, 0x70, 0x6f, 0x6c, + 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x6f, + 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x50, + 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x92, 0x01, 0x02, 0x08, 0x01, 0x52, + 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x5f, + 0x62, 0x75, 0x69, 0x6c, 0x74, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, + 0x73, 0x42, 0x75, 0x69, 0x6c, 0x74, 0x69, 0x6e, 0x22, 0xff, 0x02, 0x0a, 0x13, 0x50, 0x72, 0x6f, + 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x12, 0x45, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x27, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, - 0x41, 0x43, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x92, - 0x01, 0x02, 0x08, 0x01, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, - 0x50, 0x0a, 0x07, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0e, - 0x32, 0x1f, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, - 0x52, 0x42, 0x41, 0x43, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x42, 0x15, 0xfa, 0x42, 0x05, 0x92, 0x01, 0x02, 0x08, 0x01, 0xfa, 0x42, 0x0a, 0x92, 0x01, - 0x07, 0x22, 0x05, 0x82, 0x01, 0x02, 0x10, 0x01, 0x52, 0x07, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x73, 0x22, 0x48, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x07, 0x0a, 0x03, 0x41, - 0x4c, 0x4c, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x47, 0x45, 0x54, 0x10, 0x01, 0x12, 0x08, 0x0a, - 0x04, 0x4c, 0x49, 0x53, 0x54, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, - 0x45, 0x10, 0x03, 0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x04, 0x12, - 0x0a, 0x0a, 0x06, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x05, 0x42, 0x25, 0x5a, 0x23, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, - 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, - 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x41, 0x43, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x82, 0x01, 0x02, 0x10, + 0x01, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x58, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, + 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x42, + 0x18, 0xfa, 0x42, 0x09, 0x9a, 0x01, 0x06, 0x22, 0x04, 0x72, 0x02, 0x10, 0x01, 0xfa, 0x42, 0x09, + 0x9a, 0x01, 0x06, 0x2a, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x8b, 0x01, 0x0a, + 0x0c, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x07, 0x0a, + 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x41, 0x50, 0x50, 0x4c, 0x49, 0x43, + 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x44, 0x45, 0x50, 0x4c, 0x4f, + 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x56, 0x45, 0x4e, 0x54, + 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x50, 0x49, 0x50, 0x45, 0x44, 0x10, 0x04, 0x12, 0x14, 0x0a, + 0x10, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x43, 0x48, 0x41, 0x49, + 0x4e, 0x10, 0x05, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x10, 0x06, + 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x50, 0x49, 0x5f, 0x4b, 0x45, 0x59, 0x10, 0x07, 0x12, 0x0b, 0x0a, + 0x07, 0x49, 0x4e, 0x53, 0x49, 0x47, 0x48, 0x54, 0x10, 0x08, 0x22, 0xf3, 0x01, 0x0a, 0x11, 0x50, + 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, + 0x12, 0x42, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x72, 0x6f, 0x6a, + 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x42, + 0x08, 0xfa, 0x42, 0x05, 0x92, 0x01, 0x02, 0x08, 0x01, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x73, 0x12, 0x50, 0x0a, 0x07, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x72, + 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x42, 0x41, 0x43, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x2e, + 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x15, 0xfa, 0x42, 0x05, 0x92, 0x01, 0x02, 0x08, 0x01, + 0xfa, 0x42, 0x0a, 0x92, 0x01, 0x07, 0x22, 0x05, 0x82, 0x01, 0x02, 0x10, 0x01, 0x52, 0x07, 0x61, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x48, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x47, 0x45, 0x54, + 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x4c, 0x49, 0x53, 0x54, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, + 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x03, 0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44, 0x41, + 0x54, 0x45, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x05, + 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, + 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, + 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1098,7 +1254,7 @@ func file_pkg_model_project_proto_rawDescGZIP() []byte { } var file_pkg_model_project_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_pkg_model_project_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_pkg_model_project_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_pkg_model_project_proto_goTypes = []interface{}{ (ProjectSSOConfig_Provider)(0), // 0: model.ProjectSSOConfig.Provider (ProjectRBACResource_ResourceType)(0), // 1: model.ProjectRBACResource.ResourceType @@ -1113,7 +1269,8 @@ var file_pkg_model_project_proto_goTypes = []interface{}{ (*ProjectRBACPolicy)(nil), // 10: model.ProjectRBACPolicy (*ProjectSSOConfig_GitHub)(nil), // 11: model.ProjectSSOConfig.GitHub (*ProjectSSOConfig_Google)(nil), // 12: model.ProjectSSOConfig.Google - nil, // 13: model.ProjectRBACResource.LabelsEntry + (*ProjectSSOConfig_Oidc)(nil), // 13: model.ProjectSSOConfig.Oidc + nil, // 14: model.ProjectRBACResource.LabelsEntry } var file_pkg_model_project_proto_depIdxs = []int32{ 4, // 0: model.Project.static_admin:type_name -> model.ProjectStaticUser @@ -1124,16 +1281,17 @@ var file_pkg_model_project_proto_depIdxs = []int32{ 0, // 5: model.ProjectSSOConfig.provider:type_name -> model.ProjectSSOConfig.Provider 11, // 6: model.ProjectSSOConfig.github:type_name -> model.ProjectSSOConfig.GitHub 12, // 7: model.ProjectSSOConfig.google:type_name -> model.ProjectSSOConfig.Google - 10, // 8: model.ProjectRBACRole.policies:type_name -> model.ProjectRBACPolicy - 1, // 9: model.ProjectRBACResource.type:type_name -> model.ProjectRBACResource.ResourceType - 13, // 10: model.ProjectRBACResource.labels:type_name -> model.ProjectRBACResource.LabelsEntry - 9, // 11: model.ProjectRBACPolicy.resources:type_name -> model.ProjectRBACResource - 2, // 12: model.ProjectRBACPolicy.actions:type_name -> model.ProjectRBACPolicy.Action - 13, // [13:13] is the sub-list for method output_type - 13, // [13:13] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 13, // 8: model.ProjectSSOConfig.oidc:type_name -> model.ProjectSSOConfig.Oidc + 10, // 9: model.ProjectRBACRole.policies:type_name -> model.ProjectRBACPolicy + 1, // 10: model.ProjectRBACResource.type:type_name -> model.ProjectRBACResource.ResourceType + 14, // 11: model.ProjectRBACResource.labels:type_name -> model.ProjectRBACResource.LabelsEntry + 9, // 12: model.ProjectRBACPolicy.resources:type_name -> model.ProjectRBACResource + 2, // 13: model.ProjectRBACPolicy.actions:type_name -> model.ProjectRBACPolicy.Action + 14, // [14:14] is the sub-list for method output_type + 14, // [14:14] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_pkg_model_project_proto_init() } @@ -1262,6 +1420,18 @@ func file_pkg_model_project_proto_init() { return nil } } + file_pkg_model_project_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProjectSSOConfig_Oidc); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -1269,7 +1439,7 @@ func file_pkg_model_project_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_pkg_model_project_proto_rawDesc, NumEnums: 3, - NumMessages: 11, + NumMessages: 12, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/model/project.pb.validate.go b/pkg/model/project.pb.validate.go index 70b1b06ce6..edc52f9ea7 100644 --- a/pkg/model/project.pb.validate.go +++ b/pkg/model/project.pb.validate.go @@ -566,6 +566,35 @@ func (m *ProjectSSOConfig) validate(all bool) error { } } + if all { + switch v := interface{}(m.GetOidc()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ProjectSSOConfigValidationError{ + field: "Oidc", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ProjectSSOConfigValidationError{ + field: "Oidc", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetOidc()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ProjectSSOConfigValidationError{ + field: "Oidc", + reason: "embedded message failed validation", + cause: err, + } + } + } + if len(errors) > 0 { return ProjectSSOConfigMultiError(errors) } @@ -1609,3 +1638,139 @@ var _ interface { Cause() error ErrorName() string } = ProjectSSOConfig_GoogleValidationError{} + +// Validate checks the field values on ProjectSSOConfig_Oidc with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *ProjectSSOConfig_Oidc) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ProjectSSOConfig_Oidc with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ProjectSSOConfig_OidcMultiError, or nil if none found. +func (m *ProjectSSOConfig_Oidc) ValidateAll() error { + return m.validate(true) +} + +func (m *ProjectSSOConfig_Oidc) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if utf8.RuneCountInString(m.GetClientId()) < 1 { + err := ProjectSSOConfig_OidcValidationError{ + field: "ClientId", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetClientSecret()) < 1 { + err := ProjectSSOConfig_OidcValidationError{ + field: "ClientSecret", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + // no validation rules for Issuer + + // no validation rules for RedirectUri + + // no validation rules for AuthorizationEndpoint + + // no validation rules for TokenEndpoint + + // no validation rules for UserInfoEndpoint + + // no validation rules for ProxyUrl + + if len(errors) > 0 { + return ProjectSSOConfig_OidcMultiError(errors) + } + + return nil +} + +// ProjectSSOConfig_OidcMultiError is an error wrapping multiple validation +// errors returned by ProjectSSOConfig_Oidc.ValidateAll() if the designated +// constraints aren't met. +type ProjectSSOConfig_OidcMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ProjectSSOConfig_OidcMultiError) Error() string { + var msgs []string + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ProjectSSOConfig_OidcMultiError) AllErrors() []error { return m } + +// ProjectSSOConfig_OidcValidationError is the validation error returned by +// ProjectSSOConfig_Oidc.Validate if the designated constraints aren't met. +type ProjectSSOConfig_OidcValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ProjectSSOConfig_OidcValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ProjectSSOConfig_OidcValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ProjectSSOConfig_OidcValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ProjectSSOConfig_OidcValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ProjectSSOConfig_OidcValidationError) ErrorName() string { + return "ProjectSSOConfig_OidcValidationError" +} + +// Error satisfies the builtin error interface +func (e ProjectSSOConfig_OidcValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sProjectSSOConfig_Oidc.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ProjectSSOConfig_OidcValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ProjectSSOConfig_OidcValidationError{} diff --git a/pkg/model/project.proto b/pkg/model/project.proto index eeddff9e94..91f0e8b2e5 100644 --- a/pkg/model/project.proto +++ b/pkg/model/project.proto @@ -74,6 +74,7 @@ message ProjectSSOConfig { GITHUB = 0; GOOGLE = 2; + OIDC = 3; } message GitHub { @@ -96,11 +97,33 @@ message ProjectSSOConfig { string client_secret = 2 [(validate.rules).string.min_len = 1]; } + message Oidc { + // The client id string of OpenID Connect oauth app. + string client_id = 1 [(validate.rules).string.min_len = 1]; + // The client secret string of OpenID Connect oauth app. + string client_secret = 2 [(validate.rules).string.min_len = 1]; + // The address of OpenID Connect service. + string issuer = 3; + // The address of the redirect uri. + string redirect_uri = 4; + // The address of the authorization endpoint. + string authorization_endpoint = 5; + // The address of the token endpoint. + string token_endpoint = 6; + // The address of the user info endpoint. + string user_info_endpoint = 7; + // The address of the proxy used while communicating with the OpenID Connect service. + string proxy_url = 8; + // scopes to request from the OpenID Connect service. + repeated string scopes = 9; + } + Provider provider = 1 [(validate.rules).enum.defined_only = true]; // The session ttl for users (hours) int64 session_ttl = 2 [(validate.rules).int64.gt = 0]; GitHub github = 10; Google google = 11; + Oidc oidc = 12; } message ProjectRBACConfig { diff --git a/pkg/oauth/oidc/oidc.go b/pkg/oauth/oidc/oidc.go new file mode 100644 index 0000000000..64c067971a --- /dev/null +++ b/pkg/oauth/oidc/oidc.go @@ -0,0 +1,209 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package oidc + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "golang.org/x/oauth2" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/golang-jwt/jwt" + "github.com/pipe-cd/pipecd/pkg/model" +) + +const ( + listPerPage = 100 +) + +// OAuthClient is a oauth client for OIDC. +type OAuthClient struct { + *oidc.Provider + *oauth2.Token + + project *model.Project +} + +// NewOAuthClient creates a new oauth client for OIDC. +func NewOAuthClient(ctx context.Context, + sso *model.ProjectSSOConfig_Oidc, + project *model.Project, + code string, +) (*OAuthClient, error) { + c := &OAuthClient{ + project: project, + } + + provider, err := oidc.NewProvider(ctx, sso.Issuer) + if err != nil { + return nil, err + } + c.Provider = provider + + cfg := oauth2.Config{ + ClientID: sso.ClientId, + ClientSecret: sso.ClientSecret, + RedirectURL: sso.RedirectUri, + Endpoint: provider.Endpoint(), + Scopes: append(sso.Scopes, oidc.ScopeOpenID), + } + + if sso.ProxyUrl != "" { + proxyURL, err := url.Parse(sso.ProxyUrl) + if err != nil { + return nil, err + } + + t := http.DefaultTransport.(*http.Transport).Clone() + t.Proxy = http.ProxyURL(proxyURL) + ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: t}) + } + + oauth2Token, err := cfg.Exchange(ctx, code) + if err != nil { + return nil, err + } + c.Token = oauth2Token + + return c, nil +} + +// GetUser returns a user model. +func (c *OAuthClient) GetUser(ctx context.Context, clientId string) (*model.User, error) { + + idTokenRAW, ok := c.Token.Extra("id_token").(string) + if !ok { + return nil, fmt.Errorf("no access_token in oauth2 token") + } + + verifier := c.Provider.Verifier(&oidc.Config{ClientID: clientId}) + idToken, err := verifier.Verify(ctx, idTokenRAW) + if err != nil { + return nil, err + } + + var claims jwt.MapClaims + if err := idToken.Claims(&claims); err != nil { + return nil, err + } + + role, err := c.decideRole(claims) + if err != nil { + return nil, err + } + + username, avatarUrl, err := c.decideUserInfos(claims) + if err != nil { + return nil, err + } + return &model.User{ + Username: username, + AvatarUrl: avatarUrl, + Role: role, + }, nil +} + +func (c *OAuthClient) decideRole(claims jwt.MapClaims) (role *model.Role, err error) { + roleStrings := make([]string, 0) + keys := []string{"groups", "roles", "cognito:groups", "custom:roles", "custom:groups"} + + role = &model.Role{ + ProjectId: c.project.Id, + ProjectRbacRoles: roleStrings, + } + + for _, key := range keys { + val, ok := claims[key] + if !ok || val == nil { + continue + } + switch val := val.(type) { + case []interface{}: + for _, item := range val { + if str, ok := item.(string); ok { + roleStrings = append(roleStrings, str) + } + } + case []string: + roleStrings = append(roleStrings, val...) + case string: + if val != "" { + roleStrings = append(roleStrings, val) + } + } + } + + // Check if the current user belongs to any registered teams. + for _, r := range roleStrings { + if r == model.BuiltinRBACRoleAdmin.String() { + role.ProjectRbacRoles = append(role.ProjectRbacRoles, model.BuiltinRBACRoleAdmin.String()) + } + if r == model.BuiltinRBACRoleEditor.String() { + role.ProjectRbacRoles = append(role.ProjectRbacRoles, model.BuiltinRBACRoleEditor.String()) + } + if r == model.BuiltinRBACRoleViewer.String() { + role.ProjectRbacRoles = append(role.ProjectRbacRoles, model.BuiltinRBACRoleViewer.String()) + } + } + + // In case the current user does not have any role + // if AllowStrayAsViewer option is set, assign Viewer role + // as user's role. + if c.project.AllowStrayAsViewer && len(roleStrings) == 0 { + role.ProjectRbacRoles = []string{model.BuiltinRBACRoleViewer.String()} + return + } + + if len(roleStrings) == 0 { + err = fmt.Errorf("no role found in claims") + return + } + + return +} + +func (c *OAuthClient) decideUserInfos(claims jwt.MapClaims) (username, avatarUrl string, err error) { + usernameKeys := []string{"username", "name", "preferred_username", "cognito:username"} + avatarUrlKey := "avatar_url" + + username = "" + for _, key := range usernameKeys { + val, ok := claims[key] + if ok && val != nil { + if str, ok := val.(string); ok && str != "" { + username = str + break + } + } + } + + if username == "" { + err = fmt.Errorf("no username found in claims") + return + } + + avatarUrl = "" + val, ok := claims[avatarUrlKey] + if ok && val != nil { + if str, ok := val.(string); ok && str != "" { + avatarUrl = str + } + } + + return username, avatarUrl, nil +} diff --git a/pkg/oauth/oidc/oidc_test.go b/pkg/oauth/oidc/oidc_test.go new file mode 100644 index 0000000000..f94a31c29b --- /dev/null +++ b/pkg/oauth/oidc/oidc_test.go @@ -0,0 +1,201 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package oidc + +import ( + "fmt" + "testing" + + "github.com/golang-jwt/jwt" + "github.com/pipe-cd/pipecd/pkg/model" + "github.com/stretchr/testify/assert" +) + +func TestDecideRole(t *testing.T) { + cases := []struct { + claims jwt.MapClaims + oc *OAuthClient + expected *model.Role + err error + }{ + { + claims: jwt.MapClaims{ + "groups": []interface{}{model.BuiltinRBACRoleAdmin.String(), model.BuiltinRBACRoleEditor.String()}, + }, + oc: &OAuthClient{ + project: &model.Project{ + Id: "project-id", + AllowStrayAsViewer: false, + UserGroups: nil, + }, + }, + expected: &model.Role{ + ProjectId: "project-id", + ProjectRbacRoles: []string{model.BuiltinRBACRoleAdmin.String(), model.BuiltinRBACRoleEditor.String()}, + }, + err: nil, + }, + { + claims: jwt.MapClaims{ + "roles": []interface{}{model.BuiltinRBACRoleEditor.String()}, + }, + oc: &OAuthClient{ + project: &model.Project{ + Id: "project-id", + AllowStrayAsViewer: false, + UserGroups: nil, + }, + }, + expected: &model.Role{ + ProjectId: "project-id", + ProjectRbacRoles: []string{model.BuiltinRBACRoleEditor.String()}, + }, + err: nil, + }, + { + claims: jwt.MapClaims{ + "custom:groups": []interface{}{model.BuiltinRBACRoleViewer.String()}, + }, + oc: &OAuthClient{ + project: &model.Project{ + Id: "project-id", + AllowStrayAsViewer: false, + UserGroups: nil, + }, + }, + expected: &model.Role{ + ProjectId: "project-id", + ProjectRbacRoles: []string{model.BuiltinRBACRoleViewer.String()}, + }, + err: nil, + }, + { + claims: jwt.MapClaims{}, + oc: &OAuthClient{ + project: &model.Project{ + Id: "project-id", + AllowStrayAsViewer: true, + UserGroups: nil, + }, + }, + expected: &model.Role{ + ProjectId: "project-id", + ProjectRbacRoles: []string{model.BuiltinRBACRoleViewer.String()}, + }, + err: nil, + }, + { + claims: jwt.MapClaims{}, + oc: &OAuthClient{ + project: &model.Project{ + Id: "project-id", + AllowStrayAsViewer: false, + UserGroups: nil, + }, + }, + expected: nil, + err: fmt.Errorf("no role found in claims"), + }, + } + + for _, c := range cases { + role, err := c.oc.decideRole(c.claims) + if c.err != nil { + assert.Error(t, err) + assert.Equal(t, c.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + assert.Equal(t, c.expected, role) + } + } +} + +func TestDecideUserInfos(t *testing.T) { + client := &OAuthClient{} + + cases := []struct { + claims jwt.MapClaims + expectedUser string + expectedAvatar string + err error + }{ + { + claims: jwt.MapClaims{ + "username": "john_doe", + }, + expectedUser: "john_doe", + expectedAvatar: "", + err: nil, + }, + { + claims: jwt.MapClaims{ + "name": "John Doe", + }, + expectedUser: "John Doe", + expectedAvatar: "", + err: nil, + }, + { + claims: jwt.MapClaims{ + "preferred_username": "johnny", + }, + expectedUser: "johnny", + expectedAvatar: "", + err: nil, + }, + { + claims: jwt.MapClaims{ + "cognito:username": "john_cognito", + }, + expectedUser: "john_cognito", + expectedAvatar: "", + err: nil, + }, + { + claims: jwt.MapClaims{ + "avatar_url": "http://example.com/avatar.jpg", + }, + expectedUser: "", + expectedAvatar: "http://example.com/avatar.jpg", + err: fmt.Errorf("no username found in claims"), + }, + { + claims: jwt.MapClaims{ + "username": "john_doe", + "avatar_url": "http://example.com/avatar.jpg", + }, + expectedUser: "john_doe", + expectedAvatar: "http://example.com/avatar.jpg", + err: nil, + }, + { + claims: jwt.MapClaims{}, + expectedUser: "", + expectedAvatar: "", + err: fmt.Errorf("no username found in claims"), + }, + } + + for _, c := range cases { + username, avatarUrl, err := client.decideUserInfos(c.claims) + if c.err != nil { + assert.Error(t, err) + assert.Equal(t, c.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + assert.Equal(t, c.expectedUser, username) + assert.Equal(t, c.expectedAvatar, avatarUrl) + } + } +} From 8be72c02afc09aa3776d7c1096b50b651408e3b6 Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Sat, 6 Jul 2024 01:06:40 +0900 Subject: [PATCH 03/14] fix: add picture claim key Signed-off-by: kumo-rn5s --- pkg/oauth/oidc/oidc.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/oauth/oidc/oidc.go b/pkg/oauth/oidc/oidc.go index 64c067971a..02561c5ab6 100644 --- a/pkg/oauth/oidc/oidc.go +++ b/pkg/oauth/oidc/oidc.go @@ -27,9 +27,9 @@ import ( "github.com/pipe-cd/pipecd/pkg/model" ) -const ( - listPerPage = 100 -) +var usernameClaimKeys = []string{"username", "preferred_username", "cognito:username"} +var avatarUrlClaimKeys = []string{"picture", "avatar_url"} +var roleClaimKeys = []string{"groups", "roles", "cognito:groups", "custom:roles", "custom:groups"} // OAuthClient is a oauth client for OIDC. type OAuthClient struct { @@ -120,14 +120,13 @@ func (c *OAuthClient) GetUser(ctx context.Context, clientId string) (*model.User func (c *OAuthClient) decideRole(claims jwt.MapClaims) (role *model.Role, err error) { roleStrings := make([]string, 0) - keys := []string{"groups", "roles", "cognito:groups", "custom:roles", "custom:groups"} role = &model.Role{ ProjectId: c.project.Id, ProjectRbacRoles: roleStrings, } - for _, key := range keys { + for _, key := range roleClaimKeys { val, ok := claims[key] if !ok || val == nil { continue @@ -178,11 +177,9 @@ func (c *OAuthClient) decideRole(claims jwt.MapClaims) (role *model.Role, err er } func (c *OAuthClient) decideUserInfos(claims jwt.MapClaims) (username, avatarUrl string, err error) { - usernameKeys := []string{"username", "name", "preferred_username", "cognito:username"} - avatarUrlKey := "avatar_url" username = "" - for _, key := range usernameKeys { + for _, key := range usernameClaimKeys { val, ok := claims[key] if ok && val != nil { if str, ok := val.(string); ok && str != "" { @@ -198,10 +195,13 @@ func (c *OAuthClient) decideUserInfos(claims jwt.MapClaims) (username, avatarUrl } avatarUrl = "" - val, ok := claims[avatarUrlKey] - if ok && val != nil { - if str, ok := val.(string); ok && str != "" { - avatarUrl = str + for _, key := range avatarUrlClaimKeys { + val, ok := claims[key] + if ok && val != nil { + if str, ok := val.(string); ok && str != "" { + avatarUrl = str + break + } } } From d869a944b762839c6378937cdab07b4f5fba08c5 Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Sat, 6 Jul 2024 16:39:24 +0900 Subject: [PATCH 04/14] docs: generic oidc Signed-off-by: kumo-rn5s --- .../user-guide/managing-controlplane/auth.md | 122 ++++++++++++++++-- 1 file changed, 112 insertions(+), 10 deletions(-) diff --git a/docs/content/en/docs-dev/user-guide/managing-controlplane/auth.md b/docs/content/en/docs-dev/user-guide/managing-controlplane/auth.md index 6643c24d0e..f44dd5d41f 100644 --- a/docs/content/en/docs-dev/user-guide/managing-controlplane/auth.md +++ b/docs/content/en/docs-dev/user-guide/managing-controlplane/auth.md @@ -19,10 +19,14 @@ After logging, the project admin should change the provided username and passwor Single sign-on (SSO) allows users to log in to PipeCD by relying on a trusted third-party service. **Supported service** + - GitHub +- Generic OIDC > Note: In the future, we want to support such as Google Gmail, Bitbucket... +#### Github + Before configuring the SSO, you need an OAuth application of the using service. For example, GitHub SSO requires creating a GitHub OAuth application as described in this page: https://docs.github.com/en/developers/apps/creating-an-oauth-app @@ -31,6 +35,103 @@ The authorization callback URL should be `https://YOUR_PIPECD_ADDRESS/auth/callb ![](/images/settings-update-sso.png) +#### Generic OIDC + +PipeCD supports any OIDC provider, with tested providers including Keycloak, Auth0, and AWS Cognito. The only supported authentication flow currently is the Authorization Code Grant. + +Requirements: + +- The IdToken will be used to decide the user's role and username. +- The IdToken must contain information about the Username and Role. + - Supported Claims Key for Username (in order of priority): `username`, `preferred_username`, `cognito:username` + - Supported Claims Key for Role (in order of priority): `groups`, `roles`, `cognito:groups`, `custom:roles`, `custom:groups` + - Supported Claims Key for Avatar (in order of priority): `picture`, `avatar_url` + +Provider Configuration Examples: + +##### Keycloak + +- **Client authentication**: On +- **Valid redirect URIs**: `https://YOUR_PIPECD_ADDRESS/auth/callback` +- **Client scopes**: Add a new mapper to the `-dedicated` scope. For instance, map Group Membership information to the groups claim (Full group path should be off). + +- **Control Plane configuration**: + + ```yaml + apiVersion: "pipecd.dev/v1beta1" + kind: ControlPlane + spec: + sharedSSOConfigs: + - name: oidc + provider: OIDC + oidc: + clientId: + clientSecret: + issuer: https:///realms/ + redirect_uri: https:///auth/callback + scopes: + - openid + - profile + ``` + +##### Auth0 + +- **Allowed Callback URLs**: `https://YOUR_PIPECD_ADDRESS/auth/callback` +- **Control Plane configuration**: + + ```yaml + apiVersion: "pipecd.dev/v1beta1" + kind: ControlPlane + spec: + sharedSSOConfigs: + - name: oidc + provider: OIDC + oidc: + clientId: + clientSecret: + issuer: https:// + redirect_uri: https:///auth/callback + scopes: + - openid + - profile + ``` + +- **Roles/Groups Claims** + For Role or Groups information mapping using Auth0 Actions, here is an example for setting `custom:roles`: + + ```javascript + exports.onExecutePostLogin = async (event, api) => { + let namespace = "custom"; + if (namespace && !namespace.endsWith("/")) { + namespace += ":"; + } + api.idToken.setCustomClaim(namespace + "roles", event.authorization.roles); + }; + ``` + +##### AWS Cognito + +- **Allowed Callback URLs**: `https://YOUR_PIPECD_ADDRESS/auth/callback` + +- **Control Plane configuration**: + + ```yaml + apiVersion: "pipecd.dev/v1beta1" + kind: ControlPlane + spec: + sharedSSOConfigs: + - name: oidc + provider: OIDC + oidc: + clientId: + clientSecret: + issuer: https://cognito-idp..amazonaws.com/ + redirect_uri: https:///auth/callback + scopes: + - openid + - profile + ``` + The project can be configured to use a shared SSO configuration (shared OAuth application) instead of needing a new one. In that case, while creating the project, the PipeCD owner specifies the name of the shared SSO configuration should be used, and then the project admin can skip configuring SSO at the settings page. ### Role-Based Access Control (RBAC) @@ -47,23 +148,24 @@ PipeCD provides three built-in roles: The below table represents PipeCD's resources with actions on those resources. -| resource | get | list | create | update | delete | -|:--------------------|:------:|:-------:|:-------:|:-------:|:-------:| -| application | ○ | ○ | ○ | ○ | ○ | -| deployment | ○ | ○ | | ○ | | -| event | | ○ | | | | -| piped | ○ | ○ | ○ | ○ | | -| project | ○ | | | ○ | | -| apiKey | | ○ | ○ | ○ | | -| insight | ○ | | | | | - +| resource | get | list | create | update | delete | +| :---------- | :-: | :--: | :----: | :----: | :----: | +| application | ○ | ○ | ○ | ○ | ○ | +| deployment | ○ | ○ | | ○ | | +| event | | ○ | | | | +| piped | ○ | ○ | ○ | ○ | | +| project | ○ | | | ○ | | +| apiKey | | ○ | ○ | ○ | | +| insight | ○ | | | | | Each role is defined as a combination of multiple policies under this format. + ``` resources=RESOURCE_NAMES;actions=ACTION_NAMES ``` The `*` represents all resources and all actions for a resource. + ``` resources=*;actions=ACTION_NAMES resources=RESOURCE_NAMES;actions=* From 27cbaf9fc831ddff8014dcbe54c6ac26edb2d4bd Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Sat, 6 Jul 2024 19:51:36 +0900 Subject: [PATCH 05/14] fix: add name claims for auth0 Signed-off-by: kumo-rn5s --- .../en/docs-dev/user-guide/managing-controlplane/auth.md | 2 +- pkg/oauth/oidc/oidc.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/en/docs-dev/user-guide/managing-controlplane/auth.md b/docs/content/en/docs-dev/user-guide/managing-controlplane/auth.md index f44dd5d41f..a86d9e5f79 100644 --- a/docs/content/en/docs-dev/user-guide/managing-controlplane/auth.md +++ b/docs/content/en/docs-dev/user-guide/managing-controlplane/auth.md @@ -43,7 +43,7 @@ Requirements: - The IdToken will be used to decide the user's role and username. - The IdToken must contain information about the Username and Role. - - Supported Claims Key for Username (in order of priority): `username`, `preferred_username`, `cognito:username` + - Supported Claims Key for Username (in order of priority): `username`, `preferred_username`,`name`, `cognito:username` - Supported Claims Key for Role (in order of priority): `groups`, `roles`, `cognito:groups`, `custom:roles`, `custom:groups` - Supported Claims Key for Avatar (in order of priority): `picture`, `avatar_url` diff --git a/pkg/oauth/oidc/oidc.go b/pkg/oauth/oidc/oidc.go index 02561c5ab6..ea9028bed8 100644 --- a/pkg/oauth/oidc/oidc.go +++ b/pkg/oauth/oidc/oidc.go @@ -27,7 +27,7 @@ import ( "github.com/pipe-cd/pipecd/pkg/model" ) -var usernameClaimKeys = []string{"username", "preferred_username", "cognito:username"} +var usernameClaimKeys = []string{"username", "preferred_username", "name", "cognito:username"} var avatarUrlClaimKeys = []string{"picture", "avatar_url"} var roleClaimKeys = []string{"groups", "roles", "cognito:groups", "custom:roles", "custom:groups"} From ca43be6a708781eba10a2562d4f0bf9e15baea12 Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Thu, 11 Jul 2024 18:22:27 +0900 Subject: [PATCH 06/14] deps: go-jose v3.0.1 to v4 Signed-off-by: kumo-rn5s --- go.mod | 14 +++++++------- go.sum | 29 ++++++++++++++--------------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index f04184a96c..40019e2d5d 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/lambda v1.30.2 github.com/aws/aws-sdk-go-v2/service/s3 v1.31.0 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.0 - github.com/coreos/go-oidc/v3 v3.9.0 + github.com/coreos/go-oidc/v3 v3.11.0 github.com/creasty/defaults v1.6.0 github.com/envoyproxy/go-control-plane v0.12.0 github.com/envoyproxy/protoc-gen-validate v1.0.4 @@ -51,9 +51,9 @@ require ( go.opentelemetry.io/otel/trace v1.28.0 go.uber.org/atomic v1.7.0 go.uber.org/zap v1.10.1-0.20190709142728-9a9fa7d4b5f0 - golang.org/x/crypto v0.24.0 - golang.org/x/net v0.26.0 - golang.org/x/oauth2 v0.20.0 + golang.org/x/crypto v0.25.0 + golang.org/x/net v0.27.0 + golang.org/x/oauth2 v0.21.0 golang.org/x/sync v0.7.0 google.golang.org/api v0.169.0 google.golang.org/grpc v1.64.1 @@ -115,7 +115,7 @@ require ( github.com/fatih/color v1.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect - github.com/go-jose/go-jose/v3 v3.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect @@ -183,8 +183,8 @@ require ( go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.2.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.21.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/term v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect diff --git a/go.sum b/go.sum index 20cb899a7d..716c47dcb1 100644 --- a/go.sum +++ b/go.sum @@ -190,8 +190,8 @@ github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvA github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo= -github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= @@ -262,8 +262,8 @@ github.com/go-bdd/gobdd v1.1.3-0.20210205100305-4910f932a786/go.mod h1:Q3mXpW/Qm github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= -github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -746,15 +746,14 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -836,8 +835,8 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -850,8 +849,8 @@ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= -golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -932,12 +931,12 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= From 9087fcefb26c80009c38c7d440f5097104af3f15 Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Fri, 16 Aug 2024 13:47:03 +0900 Subject: [PATCH 07/14] docs: oidc references Signed-off-by: kumo-rn5s --- .../configuration-reference.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/content/en/docs-dev/user-guide/managing-controlplane/configuration-reference.md b/docs/content/en/docs-dev/user-guide/managing-controlplane/configuration-reference.md index 0645c7cd0c..72a2f10b0c 100644 --- a/docs/content/en/docs-dev/user-guide/managing-controlplane/configuration-reference.md +++ b/docs/content/en/docs-dev/user-guide/managing-controlplane/configuration-reference.md @@ -146,9 +146,10 @@ Must be one of the following objects: | Field | Type | Description | Required | |-|-|-|-| | name | string | The unique name of the configuration. | Yes | -| provider | string | The SSO service provider. Currently, only `GITHUB` is supported. | Yes | +| provider | string | The SSO service provider. Currently, only `GITHUB` and `OIDC` is supported. | Yes | | sessionTtl | int | The time to live of session for SSO login. Unit is `hour`. Default is 7 * 24 hours. | No | | github | [SSOConfigGitHub](#ssoconfiggithub) | GitHub sso configuration. | No | +| oidc | [SSOConfigOIDC](#ssoconfigoidc) | OIDC sso configuration. | No | ## SSOConfigGitHub @@ -159,3 +160,17 @@ Must be one of the following objects: | baseUrl | string | The address of GitHub service. Required if enterprise. | No | | uploadUrl | string | The upload url of GitHub service. | No | | proxyUrl | string | The address of the proxy used while communicating with the GitHub service. | No | + +## SSOConfigOIDC + +| Field | Type | Description | Required | +|-|-|-|-| +| ClientId | string | The client id string of OpenID Connect oauth app. | Yes | +| ClientSecret | string | The client secret string of OpenID Connect oauth app. | Yes | +| Issuer | string | The address of OpenID Connect service. | Yes | +| RedirectUri | string | The address of the redirect URI. | Yes | +| AuthorizationEndpoint | string | The address of the authorization endpoint. | No | +| TokenEndpoint | string | The address of the token endpoint. | No | +| UserInfoEndpoint | string | The address of the user info endpoint. | No | +| ProxyUrl | string | The address of the proxy used while communicating with the OpenID Connect service. | No | +| Scopes | []string | Scopes to request from the OpenID Connect service. Default is `openid`. Some providers may require other scopes. | No | From ddf98a38a86459c87b1d60bc7f936e4a1c97c63f Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Fri, 16 Aug 2024 13:47:41 +0900 Subject: [PATCH 08/14] fix: spelling Signed-off-by: kumo-rn5s --- pkg/model/project.pb.go | 2 +- pkg/model/project.proto | 2 +- pkg/oauth/oidc/oidc.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/model/project.pb.go b/pkg/model/project.pb.go index 112ad10fa8..a00d34d0fe 100644 --- a/pkg/model/project.pb.go +++ b/pkg/model/project.pb.go @@ -967,7 +967,7 @@ type ProjectSSOConfig_Oidc struct { UserInfoEndpoint string `protobuf:"bytes,7,opt,name=user_info_endpoint,json=userInfoEndpoint,proto3" json:"user_info_endpoint,omitempty"` // The address of the proxy used while communicating with the OpenID Connect service. ProxyUrl string `protobuf:"bytes,8,opt,name=proxy_url,json=proxyUrl,proto3" json:"proxy_url,omitempty"` - // scopes to request from the OpenID Connect service. + // Scopes to request from the OpenID Connect service. Scopes []string `protobuf:"bytes,9,rep,name=scopes,proto3" json:"scopes,omitempty"` } diff --git a/pkg/model/project.proto b/pkg/model/project.proto index 91f0e8b2e5..060f2526db 100644 --- a/pkg/model/project.proto +++ b/pkg/model/project.proto @@ -114,7 +114,7 @@ message ProjectSSOConfig { string user_info_endpoint = 7; // The address of the proxy used while communicating with the OpenID Connect service. string proxy_url = 8; - // scopes to request from the OpenID Connect service. + // Scopes to request from the OpenID Connect service. repeated string scopes = 9; } diff --git a/pkg/oauth/oidc/oidc.go b/pkg/oauth/oidc/oidc.go index ea9028bed8..e0aa4dd0c3 100644 --- a/pkg/oauth/oidc/oidc.go +++ b/pkg/oauth/oidc/oidc.go @@ -31,7 +31,7 @@ var usernameClaimKeys = []string{"username", "preferred_username", "name", "cogn var avatarUrlClaimKeys = []string{"picture", "avatar_url"} var roleClaimKeys = []string{"groups", "roles", "cognito:groups", "custom:roles", "custom:groups"} -// OAuthClient is a oauth client for OIDC. +// OAuthClient is an oauth client for OIDC. type OAuthClient struct { *oidc.Provider *oauth2.Token @@ -88,7 +88,7 @@ func (c *OAuthClient) GetUser(ctx context.Context, clientId string) (*model.User idTokenRAW, ok := c.Token.Extra("id_token").(string) if !ok { - return nil, fmt.Errorf("no access_token in oauth2 token") + return nil, fmt.Errorf("no id_token in oauth2 token") } verifier := c.Provider.Verifier(&oidc.Config{ClientID: clientId}) From db93f33705f9ede39c2104ecad4693228c6d8a6c Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Fri, 16 Aug 2024 13:47:59 +0900 Subject: [PATCH 09/14] fix: SSO UI Signed-off-by: kumo-rn5s --- web/src/components/login-page/login-form/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/login-page/login-form/index.tsx b/web/src/components/login-page/login-form/index.tsx index 0a5bf8eed7..a7746feeab 100644 --- a/web/src/components/login-page/login-form/index.tsx +++ b/web/src/components/login-page/login-form/index.tsx @@ -43,6 +43,7 @@ const useStyles = makeStyles((theme) => ({ }, oidcLoginButton: { background: "#4A90E2", + marginTop: theme.spacing(1), }, divider: { display: "flex", From 6808c9523784cff444626d9687303f78e1932231 Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Fri, 16 Aug 2024 13:48:30 +0900 Subject: [PATCH 10/14] feat: add unit test to callback functions, fix unexpected result Signed-off-by: kumo-rn5s --- pkg/app/server/httpapi/callback.go | 5 +- pkg/app/server/httpapi/callback_test.go | 80 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/pkg/app/server/httpapi/callback.go b/pkg/app/server/httpapi/callback.go index 26b4289d53..7cbc47d0e7 100644 --- a/pkg/app/server/httpapi/callback.go +++ b/pkg/app/server/httpapi/callback.go @@ -178,10 +178,13 @@ func parseProjectAndState(r *http.Request) (string, string, error) { if len(s) != 2 { projectID := r.FormValue(projectFormKey) if projectID == "" { - return "", "", fmt.Errorf("missing project id") + return s[0], "", fmt.Errorf("missing project id") } return state, projectID, nil } else { + if s[1] == "" { + return s[0], "", fmt.Errorf("missing project id") + } return s[0], s[1], nil } } diff --git a/pkg/app/server/httpapi/callback_test.go b/pkg/app/server/httpapi/callback_test.go index 1dc2a3e6d8..f2d31191a4 100644 --- a/pkg/app/server/httpapi/callback_test.go +++ b/pkg/app/server/httpapi/callback_test.go @@ -13,3 +13,83 @@ // limitations under the License. package httpapi + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseProjectAndState(t *testing.T) { + tests := []struct { + name string + formValues url.Values + expectedState string + expectedProj string + expectErr bool + }{ + { + name: "missing state", + formValues: url.Values{}, + expectedState: "", + expectedProj: "", + expectErr: true, + }, + { + name: "state without project id", + formValues: url.Values{ + stateFormKey: {"state-token"}, + }, + expectedState: "state-token", + expectedProj: "", + expectErr: true, + }, + { + name: "state with project id", + formValues: url.Values{ + stateFormKey: {"state-token:project-id"}, + }, + expectedState: "state-token", + expectedProj: "project-id", + expectErr: false, + }, + { + name: "state without colon and project id in form", + formValues: url.Values{ + stateFormKey: {"state-token"}, + projectFormKey: {"project-id"}, + }, + expectedState: "state-token", + expectedProj: "project-id", + expectErr: false, + }, + { + name: "state with colon but missing project id in form", + formValues: url.Values{ + stateFormKey: {"state-token:"}, + }, + expectedState: "state-token", + expectedProj: "", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Form = tt.formValues + + state, project, err := parseProjectAndState(req) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expectedState, state) + assert.Equal(t, tt.expectedProj, project) + }) + } +} From f1c0e136acd635c4022d1cdec5116eaa8c45cc9f Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Fri, 16 Aug 2024 13:55:35 +0900 Subject: [PATCH 11/14] add test: OIDC GenerateAuthCodeURL Signed-off-by: kumo-rn5s --- pkg/model/project_test.go | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/pkg/model/project_test.go b/pkg/model/project_test.go index 1309ad4f28..9c1997dfa1 100644 --- a/pkg/model/project_test.go +++ b/pkg/model/project_test.go @@ -853,3 +853,66 @@ func TestProject_DeleteRBACRole(t *testing.T) { }) } } + +func TestGenerateAuthCodeURL(t *testing.T) { + tests := []struct { + name string + config ProjectSSOConfig_Oidc + project string + state string + expectedAuthCodeURL string + expectedError bool + }{ + { + name: "valid config with default scope", + config: ProjectSSOConfig_Oidc{ + Issuer: "https://accounts.google.com", + ClientId: "test-client-id", + RedirectUri: "https://example.com/callback", + Scopes: []string{}, + }, + project: "test-project", + state: "test-state", + expectedAuthCodeURL: "https://accounts.google.com/o/oauth2/v2/auth?access_type=online&client_id=test-client-id&prompt=consent&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=code&scope=openid&state=test-state%3Atest-project", + expectedError: false, + }, + { + name: "valid config with custom scopes", + config: ProjectSSOConfig_Oidc{ + Issuer: "https://accounts.google.com", + ClientId: "test-client-id", + RedirectUri: "https://example.com/callback", + Scopes: []string{"openid", "profile", "email"}, + }, + project: "test-project", + state: "test-state", + expectedAuthCodeURL: "https://accounts.google.com/o/oauth2/v2/auth?access_type=online&client_id=test-client-id&prompt=consent&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=code&scope=openid+profile+email&state=test-state%3Atest-project", + expectedError: false, + }, + { + name: "invalid issuer", + config: ProjectSSOConfig_Oidc{ + Issuer: "https://invalid-issuer.com", + ClientId: "test-client-id", + RedirectUri: "https://example.com/callback", + Scopes: []string{}, + }, + project: "test-project", + state: "test-state", + expectedAuthCodeURL: "", + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authURL, err := tt.config.GenerateAuthCodeURL(tt.project, tt.state) + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedAuthCodeURL, authURL) + } + }) + } +} From ef530654dd703c5f59b8a8a65234d7028aaa97f2 Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Fri, 16 Aug 2024 14:03:04 +0900 Subject: [PATCH 12/14] fix: unit test function name Signed-off-by: kumo-rn5s --- pkg/model/project_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/model/project_test.go b/pkg/model/project_test.go index 9c1997dfa1..5948c7a096 100644 --- a/pkg/model/project_test.go +++ b/pkg/model/project_test.go @@ -854,7 +854,7 @@ func TestProject_DeleteRBACRole(t *testing.T) { } } -func TestGenerateAuthCodeURL(t *testing.T) { +func TestGenerateAuthCodeURL_Oidc(t *testing.T) { tests := []struct { name string config ProjectSSOConfig_Oidc From 5bc68ead2de6a0420d2dff9fe55b59cb10a771a8 Mon Sep 17 00:00:00 2001 From: Kumo <35224826+kumo-rn5s@users.noreply.github.com> Date: Tue, 27 Aug 2024 00:32:15 +0900 Subject: [PATCH 13/14] Update pkg/app/server/httpapi/callback.go Co-authored-by: Yoshiki Fujikane <40124947+ffjlabo@users.noreply.github.com> Signed-off-by: Kumo <35224826+kumo-rn5s@users.noreply.github.com> --- pkg/app/server/httpapi/callback.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/app/server/httpapi/callback.go b/pkg/app/server/httpapi/callback.go index 7cbc47d0e7..acac4061b8 100644 --- a/pkg/app/server/httpapi/callback.go +++ b/pkg/app/server/httpapi/callback.go @@ -41,7 +41,7 @@ func (h *authHandler) handleCallback(w http.ResponseWriter, r *http.Request) { // This is necessary because some providers don't support passing the project ID in the query parameters. state, projectID, err := parseProjectAndState(r) if err != nil { - h.handleError(w, r, err.Error(), nil) + h.handleError(w, r, "Failed to parse state", err) return } From 35ad7e9cbef041ee653ed97fc86551016f8119f8 Mon Sep 17 00:00:00 2001 From: kumo-rn5s Date: Wed, 28 Aug 2024 17:54:10 +0900 Subject: [PATCH 14/14] run: go mod tidy Signed-off-by: kumo-rn5s --- go.mod | 1 - 1 file changed, 1 deletion(-) diff --git a/go.mod b/go.mod index a44a4ca5e9..fb2f6c8827 100644 --- a/go.mod +++ b/go.mod @@ -117,7 +117,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect - github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.5 // indirect