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