mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-16 12:37:25 +02:00
Fix OAuth2 authorization code expiry and reuse handling (#36797)
- set OAuth2 authorization code `ValidUntil` on creation and add expiry checks during exchange - return a specific error when codes are invalidated twice to prevent concurrent reuse - add unit tests covering validity timestamps, expiration, and double invalidation --- Generate by a coding agent with Codex 5.2 --------- Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
57b5ed3f25
commit
f3bdcc58af
@ -14,6 +14,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/modules/container"
|
"code.gitea.io/gitea/modules/container"
|
||||||
@ -27,6 +28,11 @@ import (
|
|||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Authorization codes should expire within 10 minutes per https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2
|
||||||
|
const oauth2AuthorizationCodeValidity = 10 * time.Minute
|
||||||
|
|
||||||
|
var ErrOAuth2AuthorizationCodeInvalidated = errors.New("oauth2 authorization code already invalidated")
|
||||||
|
|
||||||
// OAuth2Application represents an OAuth2 client (RFC 6749)
|
// OAuth2Application represents an OAuth2 client (RFC 6749)
|
||||||
type OAuth2Application struct {
|
type OAuth2Application struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
@ -386,6 +392,14 @@ func (code *OAuth2AuthorizationCode) TableName() string {
|
|||||||
return "oauth2_authorization_code"
|
return "oauth2_authorization_code"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsExpired reports whether the authorization code is expired.
|
||||||
|
func (code *OAuth2AuthorizationCode) IsExpired() bool {
|
||||||
|
if code.ValidUntil.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return code.ValidUntil <= timeutil.TimeStampNow()
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateRedirectURI generates a redirect URI for a successful authorization request. State will be used if not empty.
|
// GenerateRedirectURI generates a redirect URI for a successful authorization request. State will be used if not empty.
|
||||||
func (code *OAuth2AuthorizationCode) GenerateRedirectURI(state string) (*url.URL, error) {
|
func (code *OAuth2AuthorizationCode) GenerateRedirectURI(state string) (*url.URL, error) {
|
||||||
redirect, err := url.Parse(code.RedirectURI)
|
redirect, err := url.Parse(code.RedirectURI)
|
||||||
@ -403,8 +417,14 @@ func (code *OAuth2AuthorizationCode) GenerateRedirectURI(state string) (*url.URL
|
|||||||
|
|
||||||
// Invalidate deletes the auth code from the database to invalidate this code
|
// Invalidate deletes the auth code from the database to invalidate this code
|
||||||
func (code *OAuth2AuthorizationCode) Invalidate(ctx context.Context) error {
|
func (code *OAuth2AuthorizationCode) Invalidate(ctx context.Context) error {
|
||||||
_, err := db.GetEngine(ctx).ID(code.ID).NoAutoCondition().Delete(code)
|
affected, err := db.GetEngine(ctx).ID(code.ID).NoAutoCondition().Delete(code)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrOAuth2AuthorizationCodeInvalidated
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateCodeChallenge validates the given verifier against the saved code challenge. This is part of the PKCE implementation.
|
// ValidateCodeChallenge validates the given verifier against the saved code challenge. This is part of the PKCE implementation.
|
||||||
@ -472,6 +492,7 @@ func (grant *OAuth2Grant) GenerateNewAuthorizationCode(ctx context.Context, redi
|
|||||||
// for code scanners to grab sensitive tokens.
|
// for code scanners to grab sensitive tokens.
|
||||||
codeSecret := "gta_" + base32Lower.EncodeToString(rBytes)
|
codeSecret := "gta_" + base32Lower.EncodeToString(rBytes)
|
||||||
|
|
||||||
|
validUntil := time.Now().Add(oauth2AuthorizationCodeValidity)
|
||||||
code = &OAuth2AuthorizationCode{
|
code = &OAuth2AuthorizationCode{
|
||||||
Grant: grant,
|
Grant: grant,
|
||||||
GrantID: grant.ID,
|
GrantID: grant.ID,
|
||||||
@ -479,6 +500,7 @@ func (grant *OAuth2Grant) GenerateNewAuthorizationCode(ctx context.Context, redi
|
|||||||
Code: codeSecret,
|
Code: codeSecret,
|
||||||
CodeChallenge: codeChallenge,
|
CodeChallenge: codeChallenge,
|
||||||
CodeChallengeMethod: codeChallengeMethod,
|
CodeChallengeMethod: codeChallengeMethod,
|
||||||
|
ValidUntil: timeutil.TimeStamp(validUntil.Unix()),
|
||||||
}
|
}
|
||||||
if err := db.Insert(ctx, code); err != nil {
|
if err := db.Insert(ctx, code); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@ -5,13 +5,45 @@ package auth_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestOAuth2AuthorizationCodeValidity(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
t.Run("GenerateSetsValidUntil", func(t *testing.T) {
|
||||||
|
grant := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Grant{ID: 1})
|
||||||
|
expectedValidUntil := timeutil.TimeStamp(time.Now().Unix() + 600)
|
||||||
|
code, err := grant.GenerateNewAuthorizationCode(t.Context(), "http://127.0.0.1/", "", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedValidUntil, code.ValidUntil)
|
||||||
|
assert.False(t, code.IsExpired())
|
||||||
|
assert.NoError(t, code.Invalidate(t.Context()))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Expired", func(t *testing.T) {
|
||||||
|
defer timeutil.MockSet(time.Unix(2, 0).UTC())()
|
||||||
|
|
||||||
|
code := &auth_model.OAuth2AuthorizationCode{ValidUntil: timeutil.TimeStamp(1)}
|
||||||
|
assert.True(t, code.IsExpired())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidateTwice", func(t *testing.T) {
|
||||||
|
code, err := auth_model.GetOAuth2AuthorizationByCode(t.Context(), "authcode")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if assert.NotNil(t, code) {
|
||||||
|
assert.NoError(t, code.Invalidate(t.Context()))
|
||||||
|
assert.ErrorIs(t, code.Invalidate(t.Context()), auth_model.ErrOAuth2AuthorizationCodeInvalidated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestOAuth2Application_GenerateClientSecret(t *testing.T) {
|
func TestOAuth2Application_GenerateClientSecret(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
app := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: 1})
|
app := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: 1})
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
@ -613,6 +614,14 @@ func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, s
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if authorizationCode.IsExpired() {
|
||||||
|
_ = authorizationCode.Invalidate(ctx)
|
||||||
|
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||||
|
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
|
||||||
|
ErrorDescription: "authorization code expired",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
// check if code verifier authorizes the client, PKCE support
|
// check if code verifier authorizes the client, PKCE support
|
||||||
if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
|
if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
|
||||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||||
@ -631,9 +640,15 @@ func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, s
|
|||||||
}
|
}
|
||||||
// remove token from database to deny duplicate usage
|
// remove token from database to deny duplicate usage
|
||||||
if err := authorizationCode.Invalidate(ctx); err != nil {
|
if err := authorizationCode.Invalidate(ctx); err != nil {
|
||||||
|
errDescription := "cannot process your request"
|
||||||
|
errCode := oauth2_provider.AccessTokenErrorCodeInvalidRequest
|
||||||
|
if errors.Is(err, auth.ErrOAuth2AuthorizationCodeInvalidated) {
|
||||||
|
errDescription = "authorization code already used"
|
||||||
|
errCode = oauth2_provider.AccessTokenErrorCodeInvalidGrant
|
||||||
|
}
|
||||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
|
ErrorCode: errCode,
|
||||||
ErrorDescription: "cannot proceed your request",
|
ErrorDescription: errDescription,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user