From a07e67d9cc39506c149cfadcf0d6f2ab234cf7f6 Mon Sep 17 00:00:00 2001 From: Johnny Oskarsson Date: Fri, 1 Jan 2021 17:33:27 +0100 Subject: [PATCH] Minimal OpenID Connect implementation (#14139) This is "minimal" in the sense that only the Authorization Code Flow from OpenID Connect Core is implemented. No discovery, no configuration endpoint, and no user scope management. OpenID Connect is an extension to the (already implemented) OAuth 2.0 protocol, and essentially an `id_token` JWT is added to the access token endpoint response when using the Authorization Code Flow. I also added support for the "nonce" field since it is required to be used in the id_token if the client decides to include it in its initial request. In order to enable this extension an OAuth 2.0 scope containing "openid" is needed. Other OAuth 2.0 requests should not be impacted by this change. This minimal implementation is enough to enable single sign-on (SSO) for other sites, e.g. by using something like `mod_auth_openidc` to only allow access to a CI server if a user has logged into Gitea. Fixes: #1310 Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: Lunny Xiao Co-authored-by: zeripath --- .../content/doc/developers/oauth2-provider.md | 4 +- models/fixtures/oauth2_grant.yml | 1 + models/migrations/migrations.go | 2 + models/migrations/v164.go | 38 +++++++++++++ models/oauth2_application.go | 47 ++++++++++++++-- models/oauth2_application_test.go | 12 ++++- modules/auth/user_form.go | 4 ++ routers/user/oauth.go | 53 +++++++++++++++++-- templates/user/auth/grant.tmpl | 2 + 9 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 models/migrations/v164.go diff --git a/docs/content/doc/developers/oauth2-provider.md b/docs/content/doc/developers/oauth2-provider.md index ad2ff78e6c..29305a24ca 100644 --- a/docs/content/doc/developers/oauth2-provider.md +++ b/docs/content/doc/developers/oauth2-provider.md @@ -30,7 +30,9 @@ Gitea supports acting as an OAuth2 provider to allow third party applications to ## Supported OAuth2 Grants -At the moment Gitea only supports the [**Authorization Code Grant**](https://tools.ietf.org/html/rfc6749#section-1.3.1) standard with additional support of the [Proof Key for Code Exchange (PKCE)](https://tools.ietf.org/html/rfc7636) extension. +At the moment Gitea only supports the [**Authorization Code Grant**](https://tools.ietf.org/html/rfc6749#section-1.3.1) standard with additional support of the following extensions: +- [Proof Key for Code Exchange (PKCE)](https://tools.ietf.org/html/rfc7636) +- [OpenID Connect (OIDC)](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) To use the Authorization Code Grant as a third party application it is required to register a new application via the "Settings" (`/user/settings/applications`) section of the settings. diff --git a/models/fixtures/oauth2_grant.yml b/models/fixtures/oauth2_grant.yml index 113eec7e5c..105e3f22db 100644 --- a/models/fixtures/oauth2_grant.yml +++ b/models/fixtures/oauth2_grant.yml @@ -2,5 +2,6 @@ user_id: 1 application_id: 1 counter: 1 + scope: "openid profile" created_unix: 1546869730 updated_unix: 1546869730 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 6570460425..bb3adccc25 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -271,6 +271,8 @@ var migrations = []Migration{ NewMigration("Convert webhook task type from int to string", convertWebhookTaskTypeToString), // v163 -> v164 NewMigration("Convert topic name from 25 to 50", convertTopicNameFrom25To50), + // v164 -> v165 + NewMigration("Add scope and nonce columns to oauth2_grant table", addScopeAndNonceColumnsToOAuth2Grant), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v164.go b/models/migrations/v164.go new file mode 100644 index 0000000000..01ba796563 --- /dev/null +++ b/models/migrations/v164.go @@ -0,0 +1,38 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + + "xorm.io/xorm" +) + +// OAuth2Grant here is a snapshot of models.OAuth2Grant for this version +// of the database, as it does not appear to have been added as a part +// of a previous migration. +type OAuth2Grant struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX unique(user_application)"` + ApplicationID int64 `xorm:"INDEX unique(user_application)"` + Counter int64 `xorm:"NOT NULL DEFAULT 1"` + Scope string `xorm:"TEXT"` + Nonce string `xorm:"TEXT"` + CreatedUnix int64 `xorm:"created"` + UpdatedUnix int64 `xorm:"updated"` +} + +// TableName sets the database table name to be the correct one, as the +// autogenerated table name for this struct is "o_auth2_grant". +func (grant *OAuth2Grant) TableName() string { + return "oauth2_grant" +} + +func addScopeAndNonceColumnsToOAuth2Grant(x *xorm.Engine) error { + if err := x.Sync2(new(OAuth2Grant)); err != nil { + return fmt.Errorf("Sync2: %v", err) + } + return nil +} diff --git a/models/oauth2_application.go b/models/oauth2_application.go index af4d280d0c..1b544e4e9e 100644 --- a/models/oauth2_application.go +++ b/models/oauth2_application.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "fmt" "net/url" + "strings" "time" "code.gitea.io/gitea/modules/secret" @@ -103,14 +104,15 @@ func (app *OAuth2Application) getGrantByUserID(e Engine, userID int64) (grant *O } // CreateGrant generates a grant for an user -func (app *OAuth2Application) CreateGrant(userID int64) (*OAuth2Grant, error) { - return app.createGrant(x, userID) +func (app *OAuth2Application) CreateGrant(userID int64, scope string) (*OAuth2Grant, error) { + return app.createGrant(x, userID, scope) } -func (app *OAuth2Application) createGrant(e Engine, userID int64) (*OAuth2Grant, error) { +func (app *OAuth2Application) createGrant(e Engine, userID int64, scope string) (*OAuth2Grant, error) { grant := &OAuth2Grant{ ApplicationID: app.ID, UserID: userID, + Scope: scope, } _, err := e.Insert(grant) if err != nil { @@ -380,6 +382,8 @@ type OAuth2Grant struct { Application *OAuth2Application `xorm:"-"` ApplicationID int64 `xorm:"INDEX unique(user_application)"` Counter int64 `xorm:"NOT NULL DEFAULT 1"` + Scope string `xorm:"TEXT"` + Nonce string `xorm:"TEXT"` CreatedUnix timeutil.TimeStamp `xorm:"created"` UpdatedUnix timeutil.TimeStamp `xorm:"updated"` } @@ -431,6 +435,30 @@ func (grant *OAuth2Grant) increaseCount(e Engine) error { return nil } +// ScopeContains returns true if the grant scope contains the specified scope +func (grant *OAuth2Grant) ScopeContains(scope string) bool { + for _, currentScope := range strings.Split(grant.Scope, " ") { + if scope == currentScope { + return true + } + } + return false +} + +// SetNonce updates the current nonce value of a grant +func (grant *OAuth2Grant) SetNonce(nonce string) error { + return grant.setNonce(x, nonce) +} + +func (grant *OAuth2Grant) setNonce(e Engine, nonce string) error { + grant.Nonce = nonce + _, err := e.ID(grant.ID).Cols("nonce").Update(grant) + if err != nil { + return err + } + return nil +} + // GetOAuth2GrantByID returns the grant with the given ID func GetOAuth2GrantByID(id int64) (*OAuth2Grant, error) { return getOAuth2GrantByID(x, id) @@ -533,3 +561,16 @@ func (token *OAuth2Token) SignToken() (string, error) { jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS512, token) return jwtToken.SignedString(setting.OAuth2.JWTSecretBytes) } + +// OIDCToken represents an OpenID Connect id_token +type OIDCToken struct { + jwt.StandardClaims + Nonce string `json:"nonce,omitempty"` +} + +// SignToken signs an id_token with the (symmetric) client secret key +func (token *OIDCToken) SignToken(clientSecret string) (string, error) { + token.IssuedAt = time.Now().Unix() + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, token) + return jwtToken.SignedString([]byte(clientSecret)) +} diff --git a/models/oauth2_application_test.go b/models/oauth2_application_test.go index 3afdf50f53..511d019465 100644 --- a/models/oauth2_application_test.go +++ b/models/oauth2_application_test.go @@ -94,11 +94,12 @@ func TestOAuth2Application_GetGrantByUserID(t *testing.T) { func TestOAuth2Application_CreateGrant(t *testing.T) { assert.NoError(t, PrepareTestDatabase()) app := AssertExistsAndLoadBean(t, &OAuth2Application{ID: 1}).(*OAuth2Application) - grant, err := app.CreateGrant(2) + grant, err := app.CreateGrant(2, "") assert.NoError(t, err) assert.NotNil(t, grant) assert.Equal(t, int64(2), grant.UserID) assert.Equal(t, int64(1), grant.ApplicationID) + assert.Equal(t, "", grant.Scope) } //////////////////// Grant @@ -122,6 +123,15 @@ func TestOAuth2Grant_IncreaseCounter(t *testing.T) { AssertExistsAndLoadBean(t, &OAuth2Grant{ID: 1, Counter: 2}) } +func TestOAuth2Grant_ScopeContains(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + grant := AssertExistsAndLoadBean(t, &OAuth2Grant{ID: 1, Scope: "openid profile"}).(*OAuth2Grant) + assert.True(t, grant.ScopeContains("openid")) + assert.True(t, grant.ScopeContains("profile")) + assert.False(t, grant.ScopeContains("profil")) + assert.False(t, grant.ScopeContains("profile2")) +} + func TestOAuth2Grant_GenerateNewAuthorizationCode(t *testing.T) { assert.NoError(t, PrepareTestDatabase()) grant := AssertExistsAndLoadBean(t, &OAuth2Grant{ID: 1}).(*OAuth2Grant) diff --git a/modules/auth/user_form.go b/modules/auth/user_form.go index c0aafec9e4..b94b8e0a4e 100644 --- a/modules/auth/user_form.go +++ b/modules/auth/user_form.go @@ -147,6 +147,8 @@ type AuthorizationForm struct { ClientID string `binding:"Required"` RedirectURI string State string + Scope string + Nonce string // PKCE support CodeChallengeMethod string // S256, plain @@ -163,6 +165,8 @@ type GrantApplicationForm struct { ClientID string `binding:"Required"` RedirectURI string State string + Scope string + Nonce string } // Validate validates the fields diff --git a/routers/user/oauth.go b/routers/user/oauth.go index 12665e94db..dda1268f8a 100644 --- a/routers/user/oauth.go +++ b/routers/user/oauth.go @@ -107,9 +107,10 @@ type AccessTokenResponse struct { TokenType TokenType `json:"token_type"` ExpiresIn int64 `json:"expires_in"` RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` } -func newAccessTokenResponse(grant *models.OAuth2Grant) (*AccessTokenResponse, *AccessTokenError) { +func newAccessTokenResponse(grant *models.OAuth2Grant, clientSecret string) (*AccessTokenResponse, *AccessTokenError) { if setting.OAuth2.InvalidateRefreshTokens { if err := grant.IncreaseCounter(); err != nil { return nil, &AccessTokenError{ @@ -153,11 +154,40 @@ func newAccessTokenResponse(grant *models.OAuth2Grant) (*AccessTokenResponse, *A } } + // generate OpenID Connect id_token + signedIDToken := "" + if grant.ScopeContains("openid") { + app, err := models.GetOAuth2ApplicationByID(grant.ApplicationID) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot find application", + } + } + idToken := &models.OIDCToken{ + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationDate.AsTime().Unix(), + Issuer: setting.AppURL, + Audience: app.ClientID, + Subject: fmt.Sprint(grant.UserID), + }, + Nonce: grant.Nonce, + } + signedIDToken, err = idToken.SignToken(clientSecret) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + } + return &AccessTokenResponse{ AccessToken: signedAccessToken, TokenType: TokenTypeBearer, ExpiresIn: setting.OAuth2.AccessTokenExpirationTime, RefreshToken: signedRefreshToken, + IDToken: signedIDToken, }, nil } @@ -264,6 +294,13 @@ func AuthorizeOAuth(ctx *context.Context, form auth.AuthorizationForm) { handleServerError(ctx, form.State, form.RedirectURI) return } + // Update nonce to reflect the new session + if len(form.Nonce) > 0 { + err := grant.SetNonce(form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } ctx.Redirect(redirect.String(), 302) return } @@ -272,6 +309,8 @@ func AuthorizeOAuth(ctx *context.Context, form auth.AuthorizationForm) { ctx.Data["Application"] = app ctx.Data["RedirectURI"] = form.RedirectURI ctx.Data["State"] = form.State + ctx.Data["Scope"] = form.Scope + ctx.Data["Nonce"] = form.Nonce ctx.Data["ApplicationUserLink"] = "@" + html.EscapeString(app.User.Name) + "" ctx.Data["ApplicationRedirectDomainHTML"] = "" + html.EscapeString(form.RedirectURI) + "" // TODO document SESSION <=> FORM @@ -313,7 +352,7 @@ func GrantApplicationOAuth(ctx *context.Context, form auth.GrantApplicationForm) ctx.ServerError("GetOAuth2ApplicationByClientID", err) return } - grant, err := app.CreateGrant(ctx.User.ID) + grant, err := app.CreateGrant(ctx.User.ID, form.Scope) if err != nil { handleAuthorizeError(ctx, AuthorizeError{ State: form.State, @@ -322,6 +361,12 @@ func GrantApplicationOAuth(ctx *context.Context, form auth.GrantApplicationForm) }, form.RedirectURI) return } + if len(form.Nonce) > 0 { + err := grant.SetNonce(form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } var codeChallenge, codeChallengeMethod string codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string) @@ -409,7 +454,7 @@ func handleRefreshToken(ctx *context.Context, form auth.AccessTokenForm) { log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID) return } - accessToken, tokenErr := newAccessTokenResponse(grant) + accessToken, tokenErr := newAccessTokenResponse(grant, form.ClientSecret) if tokenErr != nil { handleAccessTokenError(ctx, *tokenErr) return @@ -471,7 +516,7 @@ func handleAuthorizationCode(ctx *context.Context, form auth.AccessTokenForm) { ErrorDescription: "cannot proceed your request", }) } - resp, tokenErr := newAccessTokenResponse(authorizationCode.Grant) + resp, tokenErr := newAccessTokenResponse(authorizationCode.Grant, form.ClientSecret) if tokenErr != nil { handleAccessTokenError(ctx, *tokenErr) return diff --git a/templates/user/auth/grant.tmpl b/templates/user/auth/grant.tmpl index 4c637a46f8..130c2383e7 100644 --- a/templates/user/auth/grant.tmpl +++ b/templates/user/auth/grant.tmpl @@ -20,6 +20,8 @@ {{.CsrfTokenHtml}} + + Cancel