Skip to content

Commit a07e67d

Browse files
ojohnny6543lunnyzeripath
authored
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 <[email protected]> Co-authored-by: Lunny Xiao <[email protected]> Co-authored-by: zeripath <[email protected]>
1 parent 4f2f08b commit a07e67d

File tree

9 files changed

+154
-9
lines changed

9 files changed

+154
-9
lines changed

docs/content/doc/developers/oauth2-provider.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ Gitea supports acting as an OAuth2 provider to allow third party applications to
3030

3131
## Supported OAuth2 Grants
3232

33-
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.
33+
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:
34+
- [Proof Key for Code Exchange (PKCE)](https://tools.ietf.org/html/rfc7636)
35+
- [OpenID Connect (OIDC)](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth)
3436

3537
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.
3638

models/fixtures/oauth2_grant.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
user_id: 1
33
application_id: 1
44
counter: 1
5+
scope: "openid profile"
56
created_unix: 1546869730
67
updated_unix: 1546869730

models/migrations/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ var migrations = []Migration{
271271
NewMigration("Convert webhook task type from int to string", convertWebhookTaskTypeToString),
272272
// v163 -> v164
273273
NewMigration("Convert topic name from 25 to 50", convertTopicNameFrom25To50),
274+
// v164 -> v165
275+
NewMigration("Add scope and nonce columns to oauth2_grant table", addScopeAndNonceColumnsToOAuth2Grant),
274276
}
275277

276278
// GetCurrentDBVersion returns the current db version

models/migrations/v164.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2020 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migrations
6+
7+
import (
8+
"fmt"
9+
10+
"xorm.io/xorm"
11+
)
12+
13+
// OAuth2Grant here is a snapshot of models.OAuth2Grant for this version
14+
// of the database, as it does not appear to have been added as a part
15+
// of a previous migration.
16+
type OAuth2Grant struct {
17+
ID int64 `xorm:"pk autoincr"`
18+
UserID int64 `xorm:"INDEX unique(user_application)"`
19+
ApplicationID int64 `xorm:"INDEX unique(user_application)"`
20+
Counter int64 `xorm:"NOT NULL DEFAULT 1"`
21+
Scope string `xorm:"TEXT"`
22+
Nonce string `xorm:"TEXT"`
23+
CreatedUnix int64 `xorm:"created"`
24+
UpdatedUnix int64 `xorm:"updated"`
25+
}
26+
27+
// TableName sets the database table name to be the correct one, as the
28+
// autogenerated table name for this struct is "o_auth2_grant".
29+
func (grant *OAuth2Grant) TableName() string {
30+
return "oauth2_grant"
31+
}
32+
33+
func addScopeAndNonceColumnsToOAuth2Grant(x *xorm.Engine) error {
34+
if err := x.Sync2(new(OAuth2Grant)); err != nil {
35+
return fmt.Errorf("Sync2: %v", err)
36+
}
37+
return nil
38+
}

models/oauth2_application.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/base64"
1010
"fmt"
1111
"net/url"
12+
"strings"
1213
"time"
1314

1415
"code.gitea.io/gitea/modules/secret"
@@ -103,14 +104,15 @@ func (app *OAuth2Application) getGrantByUserID(e Engine, userID int64) (grant *O
103104
}
104105

105106
// CreateGrant generates a grant for an user
106-
func (app *OAuth2Application) CreateGrant(userID int64) (*OAuth2Grant, error) {
107-
return app.createGrant(x, userID)
107+
func (app *OAuth2Application) CreateGrant(userID int64, scope string) (*OAuth2Grant, error) {
108+
return app.createGrant(x, userID, scope)
108109
}
109110

110-
func (app *OAuth2Application) createGrant(e Engine, userID int64) (*OAuth2Grant, error) {
111+
func (app *OAuth2Application) createGrant(e Engine, userID int64, scope string) (*OAuth2Grant, error) {
111112
grant := &OAuth2Grant{
112113
ApplicationID: app.ID,
113114
UserID: userID,
115+
Scope: scope,
114116
}
115117
_, err := e.Insert(grant)
116118
if err != nil {
@@ -380,6 +382,8 @@ type OAuth2Grant struct {
380382
Application *OAuth2Application `xorm:"-"`
381383
ApplicationID int64 `xorm:"INDEX unique(user_application)"`
382384
Counter int64 `xorm:"NOT NULL DEFAULT 1"`
385+
Scope string `xorm:"TEXT"`
386+
Nonce string `xorm:"TEXT"`
383387
CreatedUnix timeutil.TimeStamp `xorm:"created"`
384388
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
385389
}
@@ -431,6 +435,30 @@ func (grant *OAuth2Grant) increaseCount(e Engine) error {
431435
return nil
432436
}
433437

438+
// ScopeContains returns true if the grant scope contains the specified scope
439+
func (grant *OAuth2Grant) ScopeContains(scope string) bool {
440+
for _, currentScope := range strings.Split(grant.Scope, " ") {
441+
if scope == currentScope {
442+
return true
443+
}
444+
}
445+
return false
446+
}
447+
448+
// SetNonce updates the current nonce value of a grant
449+
func (grant *OAuth2Grant) SetNonce(nonce string) error {
450+
return grant.setNonce(x, nonce)
451+
}
452+
453+
func (grant *OAuth2Grant) setNonce(e Engine, nonce string) error {
454+
grant.Nonce = nonce
455+
_, err := e.ID(grant.ID).Cols("nonce").Update(grant)
456+
if err != nil {
457+
return err
458+
}
459+
return nil
460+
}
461+
434462
// GetOAuth2GrantByID returns the grant with the given ID
435463
func GetOAuth2GrantByID(id int64) (*OAuth2Grant, error) {
436464
return getOAuth2GrantByID(x, id)
@@ -533,3 +561,16 @@ func (token *OAuth2Token) SignToken() (string, error) {
533561
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS512, token)
534562
return jwtToken.SignedString(setting.OAuth2.JWTSecretBytes)
535563
}
564+
565+
// OIDCToken represents an OpenID Connect id_token
566+
type OIDCToken struct {
567+
jwt.StandardClaims
568+
Nonce string `json:"nonce,omitempty"`
569+
}
570+
571+
// SignToken signs an id_token with the (symmetric) client secret key
572+
func (token *OIDCToken) SignToken(clientSecret string) (string, error) {
573+
token.IssuedAt = time.Now().Unix()
574+
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, token)
575+
return jwtToken.SignedString([]byte(clientSecret))
576+
}

models/oauth2_application_test.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,12 @@ func TestOAuth2Application_GetGrantByUserID(t *testing.T) {
9494
func TestOAuth2Application_CreateGrant(t *testing.T) {
9595
assert.NoError(t, PrepareTestDatabase())
9696
app := AssertExistsAndLoadBean(t, &OAuth2Application{ID: 1}).(*OAuth2Application)
97-
grant, err := app.CreateGrant(2)
97+
grant, err := app.CreateGrant(2, "")
9898
assert.NoError(t, err)
9999
assert.NotNil(t, grant)
100100
assert.Equal(t, int64(2), grant.UserID)
101101
assert.Equal(t, int64(1), grant.ApplicationID)
102+
assert.Equal(t, "", grant.Scope)
102103
}
103104

104105
//////////////////// Grant
@@ -122,6 +123,15 @@ func TestOAuth2Grant_IncreaseCounter(t *testing.T) {
122123
AssertExistsAndLoadBean(t, &OAuth2Grant{ID: 1, Counter: 2})
123124
}
124125

126+
func TestOAuth2Grant_ScopeContains(t *testing.T) {
127+
assert.NoError(t, PrepareTestDatabase())
128+
grant := AssertExistsAndLoadBean(t, &OAuth2Grant{ID: 1, Scope: "openid profile"}).(*OAuth2Grant)
129+
assert.True(t, grant.ScopeContains("openid"))
130+
assert.True(t, grant.ScopeContains("profile"))
131+
assert.False(t, grant.ScopeContains("profil"))
132+
assert.False(t, grant.ScopeContains("profile2"))
133+
}
134+
125135
func TestOAuth2Grant_GenerateNewAuthorizationCode(t *testing.T) {
126136
assert.NoError(t, PrepareTestDatabase())
127137
grant := AssertExistsAndLoadBean(t, &OAuth2Grant{ID: 1}).(*OAuth2Grant)

modules/auth/user_form.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ type AuthorizationForm struct {
147147
ClientID string `binding:"Required"`
148148
RedirectURI string
149149
State string
150+
Scope string
151+
Nonce string
150152

151153
// PKCE support
152154
CodeChallengeMethod string // S256, plain
@@ -163,6 +165,8 @@ type GrantApplicationForm struct {
163165
ClientID string `binding:"Required"`
164166
RedirectURI string
165167
State string
168+
Scope string
169+
Nonce string
166170
}
167171

168172
// Validate validates the fields

routers/user/oauth.go

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,10 @@ type AccessTokenResponse struct {
107107
TokenType TokenType `json:"token_type"`
108108
ExpiresIn int64 `json:"expires_in"`
109109
RefreshToken string `json:"refresh_token"`
110+
IDToken string `json:"id_token,omitempty"`
110111
}
111112

112-
func newAccessTokenResponse(grant *models.OAuth2Grant) (*AccessTokenResponse, *AccessTokenError) {
113+
func newAccessTokenResponse(grant *models.OAuth2Grant, clientSecret string) (*AccessTokenResponse, *AccessTokenError) {
113114
if setting.OAuth2.InvalidateRefreshTokens {
114115
if err := grant.IncreaseCounter(); err != nil {
115116
return nil, &AccessTokenError{
@@ -153,11 +154,40 @@ func newAccessTokenResponse(grant *models.OAuth2Grant) (*AccessTokenResponse, *A
153154
}
154155
}
155156

157+
// generate OpenID Connect id_token
158+
signedIDToken := ""
159+
if grant.ScopeContains("openid") {
160+
app, err := models.GetOAuth2ApplicationByID(grant.ApplicationID)
161+
if err != nil {
162+
return nil, &AccessTokenError{
163+
ErrorCode: AccessTokenErrorCodeInvalidRequest,
164+
ErrorDescription: "cannot find application",
165+
}
166+
}
167+
idToken := &models.OIDCToken{
168+
StandardClaims: jwt.StandardClaims{
169+
ExpiresAt: expirationDate.AsTime().Unix(),
170+
Issuer: setting.AppURL,
171+
Audience: app.ClientID,
172+
Subject: fmt.Sprint(grant.UserID),
173+
},
174+
Nonce: grant.Nonce,
175+
}
176+
signedIDToken, err = idToken.SignToken(clientSecret)
177+
if err != nil {
178+
return nil, &AccessTokenError{
179+
ErrorCode: AccessTokenErrorCodeInvalidRequest,
180+
ErrorDescription: "cannot sign token",
181+
}
182+
}
183+
}
184+
156185
return &AccessTokenResponse{
157186
AccessToken: signedAccessToken,
158187
TokenType: TokenTypeBearer,
159188
ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
160189
RefreshToken: signedRefreshToken,
190+
IDToken: signedIDToken,
161191
}, nil
162192
}
163193

@@ -264,6 +294,13 @@ func AuthorizeOAuth(ctx *context.Context, form auth.AuthorizationForm) {
264294
handleServerError(ctx, form.State, form.RedirectURI)
265295
return
266296
}
297+
// Update nonce to reflect the new session
298+
if len(form.Nonce) > 0 {
299+
err := grant.SetNonce(form.Nonce)
300+
if err != nil {
301+
log.Error("Unable to update nonce: %v", err)
302+
}
303+
}
267304
ctx.Redirect(redirect.String(), 302)
268305
return
269306
}
@@ -272,6 +309,8 @@ func AuthorizeOAuth(ctx *context.Context, form auth.AuthorizationForm) {
272309
ctx.Data["Application"] = app
273310
ctx.Data["RedirectURI"] = form.RedirectURI
274311
ctx.Data["State"] = form.State
312+
ctx.Data["Scope"] = form.Scope
313+
ctx.Data["Nonce"] = form.Nonce
275314
ctx.Data["ApplicationUserLink"] = "<a href=\"" + html.EscapeString(setting.AppURL) + html.EscapeString(url.PathEscape(app.User.LowerName)) + "\">@" + html.EscapeString(app.User.Name) + "</a>"
276315
ctx.Data["ApplicationRedirectDomainHTML"] = "<strong>" + html.EscapeString(form.RedirectURI) + "</strong>"
277316
// TODO document SESSION <=> FORM
@@ -313,7 +352,7 @@ func GrantApplicationOAuth(ctx *context.Context, form auth.GrantApplicationForm)
313352
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
314353
return
315354
}
316-
grant, err := app.CreateGrant(ctx.User.ID)
355+
grant, err := app.CreateGrant(ctx.User.ID, form.Scope)
317356
if err != nil {
318357
handleAuthorizeError(ctx, AuthorizeError{
319358
State: form.State,
@@ -322,6 +361,12 @@ func GrantApplicationOAuth(ctx *context.Context, form auth.GrantApplicationForm)
322361
}, form.RedirectURI)
323362
return
324363
}
364+
if len(form.Nonce) > 0 {
365+
err := grant.SetNonce(form.Nonce)
366+
if err != nil {
367+
log.Error("Unable to update nonce: %v", err)
368+
}
369+
}
325370

326371
var codeChallenge, codeChallengeMethod string
327372
codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
@@ -409,7 +454,7 @@ func handleRefreshToken(ctx *context.Context, form auth.AccessTokenForm) {
409454
log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
410455
return
411456
}
412-
accessToken, tokenErr := newAccessTokenResponse(grant)
457+
accessToken, tokenErr := newAccessTokenResponse(grant, form.ClientSecret)
413458
if tokenErr != nil {
414459
handleAccessTokenError(ctx, *tokenErr)
415460
return
@@ -471,7 +516,7 @@ func handleAuthorizationCode(ctx *context.Context, form auth.AccessTokenForm) {
471516
ErrorDescription: "cannot proceed your request",
472517
})
473518
}
474-
resp, tokenErr := newAccessTokenResponse(authorizationCode.Grant)
519+
resp, tokenErr := newAccessTokenResponse(authorizationCode.Grant, form.ClientSecret)
475520
if tokenErr != nil {
476521
handleAccessTokenError(ctx, *tokenErr)
477522
return

templates/user/auth/grant.tmpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
{{.CsrfTokenHtml}}
2121
<input type="hidden" name="client_id" value="{{.Application.ClientID}}">
2222
<input type="hidden" name="state" value="{{.State}}">
23+
<input type="hidden" name="scope" value="{{.Scope}}">
24+
<input type="hidden" name="nonce" value="{{.Nonce}}">
2325
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
2426
<input type="submit" id="authorize-app" value="{{.i18n.Tr "auth.authorize_application"}}" class="ui red inline button"/>
2527
<a href="{{.RedirectURI}}" class="ui basic primary inline button">Cancel</a>

0 commit comments

Comments
 (0)