Skip to content

Minimal OpenID Connect implementation #14139

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jan 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/content/doc/developers/oauth2-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions models/fixtures/oauth2_grant.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
user_id: 1
application_id: 1
counter: 1
scope: "openid profile"
created_unix: 1546869730
updated_unix: 1546869730
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions models/migrations/v164.go
Original file line number Diff line number Diff line change
@@ -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
}
47 changes: 44 additions & 3 deletions models/oauth2_application.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/base64"
"fmt"
"net/url"
"strings"
"time"

"code.gitea.io/gitea/modules/secret"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
}
12 changes: 11 additions & 1 deletion models/oauth2_application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions modules/auth/user_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -163,6 +165,8 @@ type GrantApplicationForm struct {
ClientID string `binding:"Required"`
RedirectURI string
State string
Scope string
Nonce string
}

// Validate validates the fields
Expand Down
53 changes: 49 additions & 4 deletions routers/user/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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"] = "<a href=\"" + html.EscapeString(setting.AppURL) + html.EscapeString(url.PathEscape(app.User.LowerName)) + "\">@" + html.EscapeString(app.User.Name) + "</a>"
ctx.Data["ApplicationRedirectDomainHTML"] = "<strong>" + html.EscapeString(form.RedirectURI) + "</strong>"
// TODO document SESSION <=> FORM
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions templates/user/auth/grant.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
{{.CsrfTokenHtml}}
<input type="hidden" name="client_id" value="{{.Application.ClientID}}">
<input type="hidden" name="state" value="{{.State}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="nonce" value="{{.Nonce}}">
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
<input type="submit" id="authorize-app" value="{{.i18n.Tr "auth.authorize_application"}}" class="ui red inline button"/>
<a href="{{.RedirectURI}}" class="ui basic primary inline button">Cancel</a>
Expand Down