diff --git a/pkg/apiserver/authentication/token/issuer.go b/pkg/apiserver/authentication/token/issuer.go index b41e8778e..4f3803ae0 100644 --- a/pkg/apiserver/authentication/token/issuer.go +++ b/pkg/apiserver/authentication/token/issuer.go @@ -61,6 +61,12 @@ type VerifiedResponse struct { Claims } +// Keys hold encryption and signing keys. +type Keys struct { + SigningKey *jose.JSONWebKey + SigningKeyPub *jose.JSONWebKey +} + // Issuer issues token to user, tokens are required to perform mutating requests to resources type Issuer interface { // IssueTo issues a token a User, return error if issuing process failed @@ -68,6 +74,9 @@ type Issuer interface { // Verify verifies a token, and return a user info if it's a valid token, otherwise return error Verify(string) (*VerifiedResponse, error) + + // Keys hold encryption and signing keys. + Keys() *Keys } type Claims struct { @@ -80,6 +89,14 @@ type Claims struct { // String value used to associate a Client session with an ID Token, and to mitigate replay attacks. // The value is passed through unmodified from the Authentication Request to the ID Token. Nonce string `json:"nonce,omitempty"` + // Scopes can be used to request that specific sets of information be made available as Claim Values. + Scopes []string `json:"scopes,omitempty"` + // End-User's preferred e-mail address. + Email string `json:"email,omitempty"` + // End-User's locale, represented as a BCP47 [RFC5646] language tag. + Locale string `json:"locale,omitempty"` + // Shorthand name by which the End-User wishes to be referred to at the RP, + PreferredUsername string `json:"preferred_username,omitempty"` // Extra contains the additional information Extra map[string][]string `json:"extra,omitempty"` } @@ -90,7 +107,7 @@ type issuer struct { // signing access_token and refresh_token secret []byte // signing id_token - signKey *jose.JSONWebKey + signKey *Keys // Token verification maximum time difference maximumClockSkew time.Duration } @@ -114,6 +131,18 @@ func (s *issuer) IssueTo(request *IssueRequest) (string, error) { if request.Nonce != "" { claims.Nonce = request.Nonce } + if request.Email != "" { + claims.Email = request.Email + } + if request.PreferredUsername != "" { + claims.PreferredUsername = request.PreferredUsername + } + if request.Locale != "" { + claims.Locale = request.Locale + } + if len(request.Scopes) > 0 { + claims.Scopes = request.Scopes + } if request.ExpiresIn > 0 { claims.ExpiresAt = claims.IssuedAt + int64(request.ExpiresIn.Seconds()) } @@ -122,8 +151,8 @@ func (s *issuer) IssueTo(request *IssueRequest) (string, error) { var err error if request.TokenType == IDToken { t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - t.Header[headerKeyID] = s.signKey.KeyID - token, err = t.SignedString(s.signKey.Key) + t.Header[headerKeyID] = s.signKey.SigningKey.KeyID + token, err = t.SignedString(s.signKey.SigningKey.Key) } else { token, err = jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret) } @@ -175,13 +204,17 @@ func (s *issuer) Verify(token string) (*VerifiedResponse, error) { return verified, nil } +func (s *issuer) Keys() *Keys { + return s.signKey +} + func (s *issuer) keyFunc(token *jwt.Token) (i interface{}, err error) { alg, _ := token.Header[headerAlgorithm].(string) switch alg { case jwt.SigningMethodHS256.Alg(): return s.secret, nil case jwt.SigningMethodRS256.Alg(): - return s.signKey.Key, nil + return s.signKey.SigningKey.Key, nil default: return nil, fmt.Errorf("unexpect signature algorithm %v", token.Header[headerAlgorithm]) } @@ -263,11 +296,19 @@ func NewIssuer(options *authentication.Options) (Issuer, error) { name: options.OAuthOptions.Issuer, secret: []byte(options.JwtSecret), maximumClockSkew: options.MaximumClockSkew, - signKey: &jose.JSONWebKey{ - Key: signKey, - KeyID: keyID, - Algorithm: jwt.SigningMethodRS256.Alg(), - Use: "sig", + signKey: &Keys{ + SigningKey: &jose.JSONWebKey{ + Key: signKey, + KeyID: keyID, + Algorithm: jwt.SigningMethodRS256.Alg(), + Use: "sig", + }, + SigningKeyPub: &jose.JSONWebKey{ + Key: signKey.Public(), + KeyID: keyID, + Algorithm: jwt.SigningMethodRS256.Alg(), + Use: "sig", + }, }, }, nil } diff --git a/pkg/apiserver/authentication/token/issuer_test.go b/pkg/apiserver/authentication/token/issuer_test.go index e7a8f6620..8530dc470 100644 --- a/pkg/apiserver/authentication/token/issuer_test.go +++ b/pkg/apiserver/authentication/token/issuer_test.go @@ -87,11 +87,19 @@ func TestNewIssuer(t *testing.T) { name: options.OAuthOptions.Issuer, secret: []byte(options.JwtSecret), maximumClockSkew: options.MaximumClockSkew, - signKey: &jose.JSONWebKey{ - Key: signKey, - KeyID: keyID, - Algorithm: jwt.SigningMethodRS256.Alg(), - Use: "sig", + signKey: &Keys{ + SigningKey: &jose.JSONWebKey{ + Key: signKey, + KeyID: keyID, + Algorithm: jwt.SigningMethodRS256.Alg(), + Use: "sig", + }, + SigningKeyPub: &jose.JSONWebKey{ + Key: signKey.Public(), + KeyID: keyID, + Algorithm: jwt.SigningMethodRS256.Alg(), + Use: "sig", + }, }, } if !reflect.DeepEqual(got, want) { @@ -116,8 +124,10 @@ func TestNewIssuerGenerateSignKey(t *testing.T) { iss := got.(*issuer) assert.NotNil(t, iss.signKey) - assert.NotNil(t, iss.signKey.Key) - assert.NotNil(t, iss.signKey.KeyID) + assert.NotNil(t, iss.signKey.SigningKey) + assert.NotNil(t, iss.signKey.SigningKeyPub) + assert.NotNil(t, iss.signKey.SigningKey.KeyID) + assert.NotNil(t, iss.signKey.SigningKeyPub.KeyID) } func Test_issuer_IssueTo(t *testing.T) { diff --git a/pkg/kapis/oauth/handler.go b/pkg/kapis/oauth/handler.go index 5f698eb4a..cc254954c 100644 --- a/pkg/kapis/oauth/handler.go +++ b/pkg/kapis/oauth/handler.go @@ -18,6 +18,8 @@ package oauth import ( "fmt" + "gopkg.in/square/go-jose.v2" + "kubesphere.io/kubesphere/pkg/utils/sliceutil" "net/http" "net/url" "strings" @@ -82,6 +84,40 @@ func (request *TokenReview) Validate() error { return nil } +// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata +type discovery struct { + // URL using the https scheme with no query or fragment component that the OP + // asserts as its Issuer Identifier. + Issuer string `json:"issuer"` + // URL of the OP's OAuth 2.0 Authorization Endpoint. + Auth string `json:"authorization_endpoint"` + // URL of the OP's OAuth 2.0 Token Endpoint. + Token string `json:"token_endpoint"` + // URL of the OP's UserInfo Endpoint + UserInfo string `json:"userinfo_endpoint"` + // URL of the OP's JSON Web Key Set [JWK] document. + Keys string `json:"jwks_uri"` + // JSON array containing a list of the OAuth 2.0 Grant Type values that this OP supports. + GrantTypes []string `json:"grant_types_supported"` + // JSON array containing a list of the OAuth 2.0 response_type values that this OP supports. + ResponseTypes []string `json:"response_types_supported"` + // JSON array containing a list of the Subject Identifier types that this OP supports. + Subjects []string `json:"subject_types_supported"` + // JSON array containing a list of the JWS signing algorithms (alg values) supported by + // the OP for the ID Token to encode the Claims in a JWT [JWT]. + IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"` + // JSON array containing a list of Proof Key for Code + // Exchange (PKCE) [RFC7636] code challenge methods supported by this authorization server. + CodeChallengeAlgs []string `json:"code_challenge_methods_supported"` + // JSON array containing a list of the OAuth 2.0 [RFC6749] scope values that this server supports. + Scopes []string `json:"scopes_supported"` + // JSON array containing a list of Client Authentication methods supported by this Token Endpoint. + AuthMethods []string `json:"token_endpoint_auth_methods_supported"` + // JSON array containing a list of the Claim Names of the Claims that the OpenID Provider + // MAY be able to supply values for. + Claims []string `json:"claims_supported"` +} + type handler struct { im im.IdentityManagementInterface options *authentication.Options @@ -139,6 +175,39 @@ func (h *handler) tokenReview(req *restful.Request, resp *restful.Response) { resp.WriteEntity(success) } +func (h *handler) discovery(req *restful.Request, response *restful.Response) { + result := discovery{ + Issuer: h.options.OAuthOptions.Issuer, + Auth: h.options.OAuthOptions.Issuer + "/authorize", + Token: h.options.OAuthOptions.Issuer + "/token", + Keys: h.options.OAuthOptions.Issuer + "/keys", + UserInfo: h.options.OAuthOptions.Issuer + "/userinfo", + Subjects: []string{"public"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + IDTokenAlgs: []string{string(jose.RS256)}, + CodeChallengeAlgs: []string{"S256", "plain"}, + Scopes: []string{"openid", "email", "profile", "offline_access"}, + // TODO(hongming) support client_secret_jwt + AuthMethods: []string{"client_secret_basic", "client_secret_post"}, + Claims: []string{ + "iss", "sub", "aud", "iat", "exp", "email", "locale", "preferred_username", + }, + ResponseTypes: []string{ + "code", + "token", + }, + } + + response.WriteEntity(result) +} + +func (h *handler) keys(req *restful.Request, response *restful.Response) { + jwks := jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{*h.tokenOperator.Keys().SigningKeyPub}, + } + response.WriteEntity(jwks) +} + // The Authorization Endpoint performs Authentication of the End-User. func (h *handler) authorize(req *restful.Request, response *restful.Response) { var scope, responseType, clientID, redirectURI, state, nonce string @@ -207,7 +276,6 @@ func (h *handler) authorize(req *restful.Request, response *restful.Response) { } redirectURL.RawQuery = values.Encode() http.Redirect(response.ResponseWriter, req.Request, redirectURL.String(), http.StatusFound) - return } // Other scope values MAY be present. @@ -233,12 +301,14 @@ func (h *handler) authorize(req *restful.Request, response *restful.Response) { }, TokenType: token.AuthorizationCode, Nonce: nonce, + Scopes: scopes, }, // A maximum authorization code lifetime of 10 minutes is ExpiresIn: 10 * time.Minute, }) if err != nil { response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + return } values := redirectURL.Query() values.Add("code", code) @@ -255,6 +325,7 @@ func (h *handler) authorize(req *restful.Request, response *restful.Response) { result, err := h.issueTokenTo(authenticated) if err != nil { response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + return } values := make(url.Values, 0) @@ -277,6 +348,7 @@ func (h *handler) oauthCallback(req *restful.Request, response *restful.Response result, err := h.issueTokenTo(authenticated) if err != nil { response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + return } requestInfo, _ := request.RequestInfoFrom(req.Request.Context()) @@ -382,6 +454,7 @@ func (h *handler) passwordGrant(username string, password string, req *restful.R result, err := h.issueTokenTo(authenticated) if err != nil { response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + return } requestInfo, _ := request.RequestInfoFrom(req.Request.Context()) @@ -392,9 +465,9 @@ func (h *handler) passwordGrant(username string, password string, req *restful.R response.WriteEntity(result) } -func (h *handler) issueTokenTo(authenticated user.Info) (*oauth.Token, error) { +func (h *handler) issueTokenTo(user user.Info) (*oauth.Token, error) { accessToken, err := h.tokenOperator.IssueTo(&token.IssueRequest{ - User: authenticated, + User: user, Claims: token.Claims{TokenType: token.AccessToken}, ExpiresIn: h.options.OAuthOptions.AccessTokenMaxAge, }) @@ -402,7 +475,7 @@ func (h *handler) issueTokenTo(authenticated user.Info) (*oauth.Token, error) { return nil, err } refreshToken, err := h.tokenOperator.IssueTo(&token.IssueRequest{ - User: authenticated, + User: user, Claims: token.Claims{TokenType: token.RefreshToken}, ExpiresIn: h.options.OAuthOptions.AccessTokenMaxAge + h.options.OAuthOptions.AccessTokenInactivityTimeout, }) @@ -469,6 +542,7 @@ func (h *handler) refreshTokenGrant(req *restful.Request, response *restful.Resp result, err := h.issueTokenTo(authenticated) if err != nil { response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + return } response.WriteEntity(result) @@ -486,26 +560,42 @@ func (h *handler) codeGrant(req *restful.Request, response *restful.Response) { response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err)) return } + if authorizeContext.TokenType != token.AuthorizationCode { err = fmt.Errorf("ivalid token type %v want %v", authorizeContext.TokenType, token.AuthorizationCode) response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err)) return } - // The client MUST NOT use the authorization code more than once. - err = h.tokenOperator.Revoke(code) - if err != nil { - klog.Warningf("grant: failed to revoke authorization code: %v", err) - } + defer func() { + // The client MUST NOT use the authorization code more than once. + err = h.tokenOperator.Revoke(code) + if err != nil { + klog.Warningf("grant: failed to revoke authorization code: %v", err) + } + }() - authenticated := authorizeContext.User - result, err := h.issueTokenTo(authenticated) + result, err := h.issueTokenTo(authorizeContext.User) if err != nil { response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + return } - idToken, err := h.tokenOperator.IssueTo(&token.IssueRequest{ - User: authenticated, + // If no openid scope value is present, the request may still be a valid OAuth 2.0 request, + // but is not an OpenID Connect request. + if !sliceutil.HasString(authorizeContext.Scopes, oauth.ScopeOpenID) { + response.WriteEntity(result) + return + } + + authenticated, err := h.im.DescribeUser(authorizeContext.User.GetName()) + if err != nil { + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + return + } + + idTokenRequest := &token.IssueRequest{ + User: authorizeContext.User, Claims: token.Claims{ StandardClaims: jwt.StandardClaims{ Audience: authorizeContext.Audience, @@ -514,8 +604,25 @@ func (h *handler) codeGrant(req *restful.Request, response *restful.Response) { TokenType: token.IDToken, }, ExpiresIn: h.options.OAuthOptions.AccessTokenMaxAge + h.options.OAuthOptions.AccessTokenInactivityTimeout, - }) + } + + if sliceutil.HasString(authorizeContext.Scopes, oauth.ScopeProfile) { + idTokenRequest.PreferredUsername = authenticated.Name + idTokenRequest.Locale = authenticated.Spec.Lang + } + + if sliceutil.HasString(authorizeContext.Scopes, oauth.ScopeEmail) { + idTokenRequest.Email = authenticated.Spec.Email + } + + idToken, err := h.tokenOperator.IssueTo(idTokenRequest) + if err != nil { + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + return + } + result.IDToken = idToken + response.WriteEntity(result) } diff --git a/pkg/kapis/oauth/register.go b/pkg/kapis/oauth/register.go index 2ba4ec5ef..ca5c485bc 100644 --- a/pkg/kapis/oauth/register.go +++ b/pkg/kapis/oauth/register.go @@ -53,6 +53,11 @@ func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, handler := newHandler(im, tokenOperator, passwordAuthenticator, oauth2Authenticator, loginRecorder, options) + ws.Route(ws.GET("/.well-known/openid-configuration").To(handler.discovery). + Doc("The OpenID Provider's configuration information can be retrieved.")) + ws.Route(ws.GET("/keys").To(handler.keys). + Doc("OP's JSON Web Key Set [JWK] document.")) + // Implement webhook authentication interface // https://kubernetes.io/docs/reference/access-authn-authz/authentication/#webhook-token-authentication ws.Route(ws.POST("/authenticate"). diff --git a/pkg/models/auth/token.go b/pkg/models/auth/token.go index 0b441dbbf..a611a8e18 100644 --- a/pkg/models/auth/token.go +++ b/pkg/models/auth/token.go @@ -41,6 +41,8 @@ type TokenManagementInterface interface { Revoke(token string) error // RevokeAllUserTokens revoke all user tokens RevokeAllUserTokens(username string) error + // Keys hold encryption and signing keys. + Keys() *token.Keys } type tokenOperator struct { @@ -117,6 +119,10 @@ func (t *tokenOperator) RevokeAllUserTokens(username string) error { return nil } +func (t *tokenOperator) Keys() *token.Keys { + return t.issuer.Keys() +} + // tokenCacheValidate verify that the token is in the cache func (t *tokenOperator) tokenCacheValidate(username, token string) error { key := fmt.Sprintf("kubesphere:user:%s:token:%s", username, token)