Skip to content

Commit

Permalink
Minimal OpenID Connect implementation (#14139)
Browse files Browse the repository at this point in the history
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 <xiaolunwen@gmail.com>
Co-authored-by: zeripath <art27@cantab.net>
  • Loading branch information
4 people authored Jan 1, 2021
1 parent 4f2f08b commit a07e67d
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 9 deletions.
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

0 comments on commit a07e67d

Please sign in to comment.