diff --git a/docs/content/doc/developers/oauth2-provider.md b/docs/content/doc/developers/oauth2-provider.md
index ad2ff78e6..29305a24c 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 113eec7e5..105e3f22d 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 657046042..bb3adccc2 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 000000000..01ba79656
--- /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 af4d280d0..1b544e4e9 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 3afdf50f5..511d01946 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 c0aafec9e..b94b8e0a4 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 12665e94d..dda1268f8 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 4c637a46f..130c2383e 100644
--- a/templates/user/auth/grant.tmpl
+++ b/templates/user/auth/grant.tmpl
@@ -20,6 +20,8 @@
{{.CsrfTokenHtml}}
+
+
Cancel