Files
probo/pkg/iam/oauth2server/service.go
Émile Ré 9156d6a16a Add wsl linter and fix
Signed-off-by: Émile Ré <emile@probo.com>
2026-05-20 09:27:28 +04:00

1775 lines
46 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"net/url"
"sync/atomic"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/hash"
"go.probo.inc/probo/pkg/crypto/jose"
"go.probo.inc/probo/pkg/crypto/rand"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/net"
"go.probo.inc/probo/pkg/uri"
)
// CLIClientID is the well-known OAuth2 client ID for the Probo CLI.
// It is inserted into every Probo database via migration and hardcoded
// in the CLI binary for the device authorization flow.
var CLIClientID = gid.MustParseGID("AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp")
const (
tokenByteLength = 32
refreshTokenByteLength = 48
tokenTypeBearer = "Bearer"
// userCodeAlphabet excludes ambiguous characters: 0/O, 1/I/L.
userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
)
type (
Service struct {
pg *pg.Client
signingKeys SigningKeys
activeSigningIdx []int
rrCounter atomic.Uint64
baseURL uri.URI
logger *log.Logger
gc *GarbageCollector
accessTokenDuration time.Duration
refreshTokenDuration time.Duration
authorizationCodeDuration time.Duration
deviceCodeDuration time.Duration
}
Option func(*Service)
AuthorizeRequest struct {
IdentityID gid.GID
SessionID gid.GID
ResponseType coredata.OAuth2ResponseType
ClientID gid.GID
RedirectURI string
Scopes coredata.OAuth2Scopes
CodeChallenge string
CodeChallengeMethod coredata.OAuth2CodeChallengeMethod
Nonce string
State string
AuthTime time.Time
}
ConsentApprovalRequest struct {
ConsentID gid.GID
IdentityID gid.GID
SessionID gid.GID
Approved bool
AuthTime time.Time
}
RegisterClientRequest struct {
IdentityID gid.GID
OrganizationID *gid.GID
ClientName string
Visibility coredata.OAuth2ClientVisibility
RedirectURIs []uri.URI
GrantTypes []coredata.OAuth2GrantType
ResponseTypes []coredata.OAuth2ResponseType
TokenEndpointAuthMethod coredata.OAuth2ClientTokenEndpointAuthMethod
LogoURI *uri.URI
ClientURI *uri.URI
Contacts []string
Scopes coredata.OAuth2Scopes
}
TokenResult struct {
AccessToken string
TokenType string
ExpiresIn int64
RefreshToken string
IDToken string
Scope string
}
IntrospectResult struct {
ClientID gid.GID
IdentityID gid.GID
Scopes coredata.OAuth2Scopes
IssuedAt time.Time
ExpiresAt time.Time
TokenType string
}
)
func WithAccessTokenDuration(d time.Duration) Option {
return func(s *Service) {
s.accessTokenDuration = d
}
}
func WithRefreshTokenDuration(d time.Duration) Option {
return func(s *Service) {
s.refreshTokenDuration = d
}
}
func WithAuthorizationCodeDuration(d time.Duration) Option {
return func(s *Service) {
s.authorizationCodeDuration = d
}
}
func WithDeviceCodeDuration(d time.Duration) Option {
return func(s *Service) {
s.deviceCodeDuration = d
}
}
func NewService(
pgClient *pg.Client,
signingKeys SigningKeys,
baseURL uri.URI,
logger *log.Logger,
opts ...Option,
) *Service {
var activeIdx []int
for i, k := range signingKeys {
if k.Active {
activeIdx = append(activeIdx, i)
}
}
s := &Service{
pg: pgClient,
signingKeys: signingKeys,
activeSigningIdx: activeIdx,
baseURL: baseURL,
logger: logger,
accessTokenDuration: 1 * time.Hour,
refreshTokenDuration: 30 * 24 * time.Hour,
authorizationCodeDuration: 10 * time.Minute,
deviceCodeDuration: 10 * time.Minute,
}
for _, opt := range opts {
opt(s)
}
s.gc = NewGarbageCollector(pgClient, logger)
return s
}
// signingKey returns the next active signing key using round-robin.
func (s *Service) signingKey() *SigningKey {
n := s.rrCounter.Add(1)
idx := s.activeSigningIdx[n%uint64(len(s.activeSigningIdx))]
return &s.signingKeys[idx]
}
func (s *Service) Run(ctx context.Context) error {
return s.gc.Run(ctx)
}
// Metadata returns the OIDC discovery document.
func (s *Service) Metadata(endpoints Endpoints) *ServerMetadata {
return NewMetadata(s.baseURL, endpoints)
}
// JWKS returns the public key set.
func (s *Service) JWKS() *jose.JWKS {
jwks := &jose.JWKS{
Keys: make([]jose.JWK, 0, len(s.signingKeys)),
}
for _, sk := range s.signingKeys {
jwks.Keys = append(
jwks.Keys,
jose.RSAPublicKeyToJWK(&sk.PrivateKey.PublicKey, sk.KID),
)
}
return jwks
}
func (s *Service) CreateAccessToken(
ctx context.Context,
clientID gid.GID,
identityID gid.GID,
scopes coredata.OAuth2Scopes,
) (string, *coredata.OAuth2AccessToken, error) {
tokenValue := rand.MustHexString(tokenByteLength)
now := time.Now()
token := &coredata.OAuth2AccessToken{
ID: gid.New(clientID.TenantID(), coredata.OAuth2AccessTokenEntityType),
HashedValue: hash.SHA256String(tokenValue),
ClientID: clientID,
IdentityID: identityID,
Scopes: scopes,
CreatedAt: now,
ExpiresAt: now.Add(s.accessTokenDuration),
}
if err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := token.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot create access token: %w", err)
}
return nil
},
); err != nil {
return "", nil, err
}
return tokenValue, token, nil
}
func (s *Service) GetClientByID(ctx context.Context, clientID gid.GID) (*coredata.OAuth2Client, error) {
client := coredata.OAuth2Client{}
if err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := client.LoadByID(ctx, conn, coredata.NewNoScope(), clientID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return NewError(ErrInvalidClient, WithDescription("client not found"))
}
return fmt.Errorf("cannot load oauth2 client: %w", err)
}
return nil
},
); err != nil {
return nil, err
}
return &client, nil
}
func (s *Service) ExchangeAuthorizationCode(
ctx context.Context,
client *coredata.OAuth2Client,
codeValue, redirectURI, codeVerifier string,
) (*TokenResult, error) {
var (
code = coredata.OAuth2AuthorizationCode{}
identity = coredata.Identity{}
now = time.Now()
accessTokenExpiresAt = now.Add(s.accessTokenDuration)
accessTokenValue = rand.MustHexString(tokenByteLength)
accessTokenID = gid.New(client.ID.TenantID(), coredata.OAuth2AccessTokenEntityType)
refreshTokenValue string
idToken string
)
if err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := code.LoadByHashForUpdate(ctx, tx, hash.SHA256String(codeValue), client.ID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return NewError(ErrInvalidGrant, WithDescription("authorization code not found"))
}
return fmt.Errorf("cannot load authorization code: %w", err)
}
// RFC 6819 §5.2.1.1: if the code was already redeemed, this is
// a replay attack. Revoke all tokens derived from this code.
if code.RedeemedAt != nil {
s.logger.WarnCtx(
ctx,
"authorization code replay detected, revoking derived tokens",
log.String("client_id", client.ID.String()),
log.String("identity_id", code.IdentityID.String()),
)
if code.AccessTokenID != nil {
derivedAccessToken := coredata.OAuth2AccessToken{ID: *code.AccessTokenID}
if err := derivedAccessToken.Delete(ctx, tx); err != nil {
s.logger.ErrorCtx(
ctx,
"cannot delete derived access token",
log.String("access_token_id", code.AccessTokenID.String()),
log.Error(err),
)
}
derivedRefreshToken := &coredata.OAuth2RefreshToken{}
if _, err := derivedRefreshToken.RevokeByAccessTokenID(ctx, tx, *code.AccessTokenID, now); err != nil {
s.logger.ErrorCtx(
ctx,
"cannot revoke derived refresh tokens",
log.String("access_token_id", code.AccessTokenID.String()),
log.Error(err),
)
}
}
return pg.NoRollback(
NewError(
ErrInvalidGrant,
WithDescription("authorization code already redeemed"),
),
)
}
if err := identity.LoadByID(ctx, tx, code.IdentityID); err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
if err := code.Redeem(ctx, tx, now, accessTokenID); err != nil {
return fmt.Errorf("cannot redeem authorization code: %w", err)
}
return nil
},
); err != nil {
return nil, err
}
if now.After(code.ExpiresAt) {
return nil, NewError(
ErrInvalidGrant,
WithDescription("authorization code expired"),
)
}
if code.RedirectURI.String() != redirectURI {
return nil, NewError(
ErrInvalidRedirectURI,
WithDescription("redirect_uri mismatch"),
)
}
if code.CodeChallenge != nil {
if codeVerifier == "" {
return nil, NewError(
ErrInvalidRequest,
WithDescription("code_verifier required"),
)
}
if !ValidateCodeChallenge(codeVerifier, *code.CodeChallenge, *code.CodeChallengeMethod) {
return nil, NewError(
ErrInvalidRequest,
WithDescription("invalid code_verifier"),
)
}
}
if code.Scopes.Contains(coredata.OAuth2ScopeOpenID) {
var (
idTokenClaims = NewIDTokenClaims(
s.baseURL,
code.IdentityID,
client.ID,
code.AuthTime,
code.Scopes,
ref.UnrefOrZero(code.Nonce),
accessTokenValue,
identity.EmailAddress.String(),
identity.EmailAddressVerified,
identity.FullName,
s.accessTokenDuration,
)
sk = s.signingKey()
err error
)
idToken, err = jose.SignJWT(sk.PrivateKey, sk.KID, idTokenClaims)
if err != nil {
return nil, fmt.Errorf("cannot sign id token: %w", err)
}
}
if err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
accessToken := &coredata.OAuth2AccessToken{
ID: accessTokenID,
HashedValue: hash.SHA256String(accessTokenValue),
ClientID: client.ID,
IdentityID: code.IdentityID,
Scopes: code.Scopes,
CreatedAt: now,
ExpiresAt: accessTokenExpiresAt,
}
if err := accessToken.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot create access token: %w", err)
}
if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && code.Scopes.Contains(coredata.OAuth2ScopeOfflineAccess) {
refreshTokenValue = rand.MustHexString(refreshTokenByteLength)
refreshToken := &coredata.OAuth2RefreshToken{
ID: gid.New(client.ID.TenantID(), coredata.OAuth2RefreshTokenEntityType),
HashedValue: hash.SHA256String(refreshTokenValue),
ClientID: client.ID,
IdentityID: code.IdentityID,
Scopes: code.Scopes,
AccessTokenID: accessToken.ID,
CreatedAt: now,
ExpiresAt: now.Add(s.refreshTokenDuration),
}
if err := refreshToken.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot create refresh token: %w", err)
}
}
return nil
},
); err != nil {
return nil, err
}
return &TokenResult{
AccessToken: accessTokenValue,
TokenType: tokenTypeBearer,
ExpiresIn: int64(time.Until(accessTokenExpiresAt).Seconds()),
RefreshToken: refreshTokenValue,
Scope: code.Scopes.String(),
IDToken: idToken,
}, nil
}
func (s *Service) RefreshToken(
ctx context.Context,
client *coredata.OAuth2Client,
refreshTokenValue string,
) (*TokenResult, error) {
var (
accessTokenValue = rand.MustHexString(tokenByteLength)
refreshTokenValueNew = rand.MustHexString(refreshTokenByteLength)
hashedValue = hash.SHA256String(refreshTokenValue)
now = time.Now()
accessTokenExpiresAt = now.Add(s.accessTokenDuration)
idToken string
previousRefreshToken = coredata.OAuth2RefreshToken{}
identity = coredata.Identity{}
)
if err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := previousRefreshToken.LoadByHashedValueForUpdate(
ctx,
tx,
hashedValue,
client.ID,
); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return NewError(
ErrInvalidGrant,
WithDescription("refresh token not found"),
)
}
return fmt.Errorf("cannot load refresh token: %w", err)
}
if err := identity.LoadByID(ctx, tx, previousRefreshToken.IdentityID); err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
if previousRefreshToken.RevokedAt != nil {
s.logger.WarnCtx(
ctx,
"refresh token replay detected, revoking all tokens",
log.String("client_id", client.ID.String()),
log.String("identity_id", previousRefreshToken.IdentityID.String()),
)
accessToken := &coredata.OAuth2AccessToken{}
if _, err := accessToken.DeleteByClientAndIdentity(
ctx,
tx,
client.ID,
previousRefreshToken.IdentityID,
); err != nil {
s.logger.ErrorCtx(
ctx,
"cannot delete access tokens",
log.String("access_token_id", previousRefreshToken.AccessTokenID.String()),
log.Error(err),
)
}
refreshToken := &coredata.OAuth2RefreshToken{}
if _, err := refreshToken.RevokeByClientAndIdentity(
ctx,
tx,
client.ID,
previousRefreshToken.IdentityID,
now,
); err != nil {
s.logger.ErrorCtx(
ctx,
"cannot revoke refresh tokens",
log.String("refresh_token_id", previousRefreshToken.ID.String()),
log.Error(err),
)
}
return pg.NoRollback(
NewError(
ErrInvalidGrant,
WithDescription("refresh token replay detected"),
),
)
}
return nil
},
); err != nil {
return nil, err
}
if now.After(previousRefreshToken.ExpiresAt) {
return nil, NewError(
ErrInvalidGrant,
WithDescription("refresh token expired"),
)
}
if previousRefreshToken.Scopes.Contains(coredata.OAuth2ScopeOpenID) {
var (
claims = NewIDTokenClaims(
s.baseURL,
previousRefreshToken.IdentityID,
client.ID,
time.Now(),
previousRefreshToken.Scopes,
"",
accessTokenValue,
identity.EmailAddress.String(),
identity.EmailAddressVerified,
identity.FullName,
s.accessTokenDuration,
)
sk = s.signingKey()
err error
)
idToken, err = jose.SignJWT(sk.PrivateKey, sk.KID, claims)
if err != nil {
return nil, fmt.Errorf("cannot sign id token: %w", err)
}
}
if err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := previousRefreshToken.Revoke(ctx, tx, now); err != nil {
return fmt.Errorf("cannot revoke previous refresh token: %w", err)
}
// Attempt to delete the previous (legacy) access token.
// If this fails, ignore the error; access tokens are short-lived and already
// unlinked from refresh tokens.
legacyAccessToken := coredata.OAuth2AccessToken{ID: previousRefreshToken.AccessTokenID}
if err := legacyAccessToken.Delete(ctx, tx); err != nil {
s.logger.ErrorCtx(
ctx,
"cannot delete legacy access token",
log.String("access_token_id", previousRefreshToken.AccessTokenID.String()),
log.Error(err),
)
}
accessToken := &coredata.OAuth2AccessToken{
ID: gid.New(client.ID.TenantID(), coredata.OAuth2AccessTokenEntityType),
HashedValue: hash.SHA256String(accessTokenValue),
ClientID: client.ID,
IdentityID: previousRefreshToken.IdentityID,
Scopes: previousRefreshToken.Scopes,
CreatedAt: now,
ExpiresAt: accessTokenExpiresAt,
}
if err := accessToken.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot create access token: %w", err)
}
refreshToken := &coredata.OAuth2RefreshToken{
ID: gid.New(client.ID.TenantID(), coredata.OAuth2RefreshTokenEntityType),
HashedValue: hash.SHA256String(refreshTokenValueNew),
ClientID: client.ID,
IdentityID: previousRefreshToken.IdentityID,
Scopes: previousRefreshToken.Scopes,
AccessTokenID: accessToken.ID,
CreatedAt: now,
ExpiresAt: now.Add(s.refreshTokenDuration),
}
if err := refreshToken.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot create refresh token: %w", err)
}
return nil
},
); err != nil {
return nil, err
}
return &TokenResult{
AccessToken: accessTokenValue,
TokenType: tokenTypeBearer,
ExpiresIn: int64(time.Until(accessTokenExpiresAt).Seconds()),
RefreshToken: refreshTokenValueNew,
Scope: previousRefreshToken.Scopes.String(),
IDToken: idToken,
}, nil
}
func (s *Service) CreateDeviceCode(
ctx context.Context,
clientID gid.GID,
scopes coredata.OAuth2Scopes,
) (string, *coredata.OAuth2DeviceCode, error) {
var (
deviceCodeValue = rand.MustHexString(tokenByteLength)
deviceCode *coredata.OAuth2DeviceCode
now = time.Now()
)
if err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
client := coredata.OAuth2Client{}
if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), clientID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return NewError(
ErrInvalidRequest,
WithDescription("unknown client_id"),
)
}
return fmt.Errorf("cannot load oauth2 client: %w", err)
}
if !client.HasGrantType(coredata.OAuth2GrantTypeDeviceCode) {
return NewError(
ErrUnauthorizedClient,
WithDescription("client not authorized for device flow"),
)
}
requestedScopes := scopes.OrDefault(client.Scopes)
if !client.AreScopesAllowed(requestedScopes) {
return NewError(
ErrInvalidScope,
WithDescription("requested scope exceeds client registration"),
)
}
if requestedScopes.Contains(coredata.OAuth2ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) {
return NewError(
ErrInvalidScope,
WithDescription("offline_access requires the refresh_token grant type"),
)
}
// Try up to 3 times to generate a unique user code, retrying if we detect a collision on insertion.
// This minimizes the (rare) chance of user code collisions due to the limited keyspace.
for range 3 {
userCode := rand.MustStringFromAlphabet(userCodeAlphabet, 8)
candidate := &coredata.OAuth2DeviceCode{
ID: gid.New(client.ID.TenantID(), coredata.OAuth2DeviceCodeEntityType),
DeviceCodeHash: hash.SHA256String(deviceCodeValue),
UserCode: coredata.OAuth2UserCode(userCode),
ClientID: client.ID,
Scopes: requestedScopes,
Status: coredata.OAuth2DeviceCodeStatusPending,
PollInterval: 5,
CreatedAt: now,
ExpiresAt: now.Add(s.deviceCodeDuration),
}
if err := candidate.Insert(ctx, tx); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
continue
}
return fmt.Errorf("cannot insert device code: %w", err)
}
deviceCode = candidate
return nil
}
return fmt.Errorf("cannot generate unique user code after 3 attempts")
},
); err != nil {
return "", nil, err
}
return deviceCodeValue, deviceCode, nil
}
func (s *Service) PollDeviceCode(
ctx context.Context,
clientID gid.GID,
deviceCodeValue string,
) (*TokenResult, error) {
var (
identity = coredata.Identity{}
hashedValue = hash.SHA256String(deviceCodeValue)
deviceCode = coredata.OAuth2DeviceCode{}
now = time.Now()
client = &coredata.OAuth2Client{}
)
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := deviceCode.LoadByDeviceCodeHashForUpdate(ctx, tx, hashedValue, clientID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return NewError(
ErrInvalidGrant,
WithDescription("invalid device code"),
)
}
return fmt.Errorf("cannot load device code: %w", err)
}
if deviceCode.IdentityID != nil {
if err := identity.LoadByID(ctx, tx, *deviceCode.IdentityID); err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
}
if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), clientID); err != nil {
return fmt.Errorf("cannot load client: %w", err)
}
// Rate limiting.
var slowDown bool
if deviceCode.LastPolledAt != nil {
elapsed := now.Sub(ref.UnrefOrZero(deviceCode.LastPolledAt))
if elapsed < time.Duration(deviceCode.PollInterval)*time.Second {
deviceCode.PollInterval += 5
slowDown = true
}
}
deviceCode.LastPolledAt = &now
if err := deviceCode.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update device code: %w", err)
}
if slowDown {
return NewError(
ErrSlowDown,
WithDescription("slow down"),
)
}
// Ensure code is deleted whehever what is happening next the code must not be used again.
if deviceCode.Status == coredata.OAuth2DeviceCodeStatusAuthorized {
if err := deviceCode.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete device code: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, err
}
if now.After(deviceCode.ExpiresAt) {
return nil, NewError(
ErrExpiredToken,
WithDescription("expired token"),
)
}
switch deviceCode.Status {
case coredata.OAuth2DeviceCodeStatusPending:
return nil, NewError(
ErrAuthorizationPending,
WithDescription("authorization pending"),
)
case coredata.OAuth2DeviceCodeStatusDenied:
return nil, NewError(
ErrAccessDenied,
WithDescription("access denied"),
)
case coredata.OAuth2DeviceCodeStatusAuthorized:
// Continue to issue tokens.
case coredata.OAuth2DeviceCodeStatusExpired:
return nil, NewError(
ErrExpiredToken,
WithDescription("expired token"),
)
default:
return nil, fmt.Errorf("invalid device code status: %q", deviceCode.Status)
}
var (
accessTokenValue = rand.MustHexString(tokenByteLength)
refreshTokenValue string
accessTokenExpiresAt = now.Add(s.accessTokenDuration)
idToken string
)
if deviceCode.Scopes.Contains(coredata.OAuth2ScopeOpenID) {
var (
claims = NewIDTokenClaims(
s.baseURL,
*deviceCode.IdentityID,
clientID,
now,
deviceCode.Scopes,
"",
accessTokenValue,
identity.EmailAddress.String(),
identity.EmailAddressVerified,
identity.FullName,
s.accessTokenDuration,
)
sk = s.signingKey()
err error
)
idToken, err = jose.SignJWT(sk.PrivateKey, sk.KID, claims)
if err != nil {
return nil, fmt.Errorf("cannot sign id token: %w", err)
}
}
if err = s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
accessToken := &coredata.OAuth2AccessToken{
ID: gid.New(clientID.TenantID(), coredata.OAuth2AccessTokenEntityType),
HashedValue: hash.SHA256String(accessTokenValue),
ClientID: clientID,
IdentityID: *deviceCode.IdentityID,
Scopes: deviceCode.Scopes,
CreatedAt: now,
ExpiresAt: accessTokenExpiresAt,
}
if err := accessToken.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot create access token: %w", err)
}
if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && deviceCode.Scopes.Contains(coredata.OAuth2ScopeOfflineAccess) {
refreshTokenValue = rand.MustHexString(refreshTokenByteLength)
refreshToken := &coredata.OAuth2RefreshToken{
ID: gid.New(clientID.TenantID(), coredata.OAuth2RefreshTokenEntityType),
HashedValue: hash.SHA256String(refreshTokenValue),
ClientID: clientID,
IdentityID: *deviceCode.IdentityID,
Scopes: deviceCode.Scopes,
AccessTokenID: accessToken.ID,
CreatedAt: now,
ExpiresAt: now.Add(s.refreshTokenDuration),
}
if err := refreshToken.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot create refresh token: %w", err)
}
}
return nil
},
); err != nil {
return nil, err
}
return &TokenResult{
AccessToken: accessTokenValue,
TokenType: tokenTypeBearer,
ExpiresIn: int64(accessTokenExpiresAt.Sub(now).Seconds()),
RefreshToken: refreshTokenValue,
Scope: deviceCode.Scopes.String(),
IDToken: idToken,
}, nil
}
func (s *Service) AuthorizeDevice(
ctx context.Context,
identityID gid.GID,
sessionID gid.GID,
userCode string,
) error {
var (
deviceCode coredata.OAuth2DeviceCode
client coredata.OAuth2Client
)
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := deviceCode.LoadByUserCodeForUpdate(ctx, tx, userCode); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return NewError(
ErrInvalidGrant,
WithDescription("invalid user code"),
)
}
return fmt.Errorf("cannot load device code: %w", err)
}
if time.Now().After(deviceCode.ExpiresAt) {
return NewError(
ErrExpiredToken,
WithDescription("expired token"),
)
}
if deviceCode.Status != coredata.OAuth2DeviceCodeStatusPending {
return NewError(
ErrInvalidGrant,
WithDescription(fmt.Sprintf("device code already %s", deviceCode.Status)),
)
}
if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), deviceCode.ClientID); err != nil {
return fmt.Errorf("cannot load oauth2 client: %w", err)
}
// RFC 6819 §5.2.3.2 / §5.2.4.1: public clients must always
// require explicit user consent since they cannot be strongly
// authenticated.
if client.TokenEndpointAuthMethod != coredata.OAuth2ClientTokenEndpointAuthMethodNone {
var existingConsent coredata.OAuth2Consent
if err := existingConsent.LoadMatchingConsent(
ctx,
tx,
identityID,
client.ID,
deviceCode.Scopes,
); err == nil {
deviceCode.Status = coredata.OAuth2DeviceCodeStatusAuthorized
deviceCode.IdentityID = &identityID
if err := deviceCode.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update device code: %w", err)
}
return nil
}
}
now := time.Now()
pendingConsent := &coredata.OAuth2Consent{
ID: gid.New(client.ID.TenantID(), coredata.OAuth2ConsentEntityType),
IdentityID: identityID,
SessionID: sessionID,
ClientID: client.ID,
Scopes: deviceCode.Scopes,
DeviceCodeID: &deviceCode.ID,
Approved: false,
CreatedAt: now,
UpdatedAt: now,
}
if err := pendingConsent.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert pending consent: %w", err)
}
return pg.NoRollback(
&ConsentRequiredError{
ConsentID: pendingConsent.ID,
Client: &client,
Scopes: deviceCode.Scopes,
},
)
},
)
}
func (s *Service) RegisterClient(
ctx context.Context,
req *RegisterClientRequest,
) (gid.GID, string, error) {
for _, u := range req.RedirectURIs {
parsed, _ := url.Parse(u.String())
switch req.Visibility {
case coredata.OAuth2ClientVisibilityPublic:
if parsed.Scheme != "https" {
return gid.Nil,
"",
NewError(
ErrInvalidRequest,
WithDescription("public clients require https redirect_uris"),
)
}
case coredata.OAuth2ClientVisibilityPrivate:
if parsed.Scheme == "http" {
if !net.IsLoopback(parsed.Hostname()) {
return gid.Nil,
"",
NewError(
ErrInvalidRequest,
WithDescription("http redirect_uris are only allowed for localhost"),
)
}
} else if parsed.Scheme != "https" {
return gid.Nil,
"",
NewError(
ErrInvalidRequest,
WithDescription(fmt.Sprintf("unsupported redirect_uri scheme: %s", parsed.Scheme)),
)
}
}
}
var (
plaintextSecret string
secretHash []byte
)
if req.TokenEndpointAuthMethod != coredata.OAuth2ClientTokenEndpointAuthMethodNone {
plaintextSecret = rand.MustHexString(tokenByteLength)
secretHash = hash.SHA256String(plaintextSecret)
}
if req.OrganizationID == nil {
return gid.Nil, "", NewError(
ErrInvalidRequest,
WithDescription("organization_id is required"),
)
}
var (
now = time.Now()
scope = coredata.NewScopeFromObjectID(*req.OrganizationID)
client = &coredata.OAuth2Client{
ID: gid.New(scope.GetTenantID(), coredata.OAuth2ClientEntityType),
OrganizationID: req.OrganizationID,
ClientSecretHash: secretHash,
ClientName: req.ClientName,
Visibility: req.Visibility,
RedirectURIs: req.RedirectURIs,
Scopes: req.Scopes,
GrantTypes: req.GrantTypes,
ResponseTypes: req.ResponseTypes,
TokenEndpointAuthMethod: req.TokenEndpointAuthMethod,
LogoURI: req.LogoURI,
ClientURI: req.ClientURI,
Contacts: req.Contacts,
CreatedAt: now,
UpdatedAt: now,
}
)
var membership coredata.Membership
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := membership.LoadActiveByIdentityIDAndOrganizationID(
ctx,
tx,
req.IdentityID,
*req.OrganizationID,
); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return NewError(
ErrAccessDenied,
WithDescription("not a member of the organization"),
)
}
return fmt.Errorf("cannot load membership: %w", err)
}
if err := client.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert oauth2 client: %w", err)
}
return nil
},
)
if err != nil {
return gid.Nil, "", err
}
return client.ID, plaintextSecret, nil
}
func (s *Service) LoadAccessToken(ctx context.Context, tokenValue string) (*coredata.OAuth2AccessToken, error) {
var (
hashedValue = hash.SHA256String(tokenValue)
token coredata.OAuth2AccessToken
now = time.Now()
)
if err := s.pg.WithConn(
ctx,
func(ctx context.Context, tx pg.Querier) error {
if err := token.LoadByHashedValue(ctx, tx, hashedValue); err != nil {
return fmt.Errorf("cannot load access token: %w", err)
}
return nil
},
); err != nil {
return nil, err
}
if now.After(token.ExpiresAt) {
return nil, fmt.Errorf("access token expired")
}
return &token, nil
}
func (s *Service) IntrospectToken(
ctx context.Context,
clientID gid.GID,
tokenValue string,
tokenTypeHint *coredata.OAuth2TokenTypeHint,
) (*IntrospectResult, error) {
var (
hashedValue = hash.SHA256String(tokenValue)
now = time.Now()
accessToken = coredata.OAuth2AccessToken{}
refreshToken = coredata.OAuth2RefreshToken{}
hasAccess bool
hasRefresh bool
)
loadAccess := func(ctx context.Context, conn pg.Querier) error {
if err := accessToken.LoadByHashedValueAndClientID(ctx, conn, hashedValue, clientID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil
}
return fmt.Errorf("cannot load access token: %w", err)
}
hasAccess = true
return nil
}
loadRefresh := func(ctx context.Context, conn pg.Querier) error {
if err := refreshToken.LoadByHashedValueAndClientID(ctx, conn, hashedValue, clientID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil
}
return fmt.Errorf("cannot load refresh token: %w", err)
}
hasRefresh = true
return nil
}
preferRefresh := tokenTypeHint != nil && *tokenTypeHint == coredata.OAuth2TokenTypeHintRefreshToken
if err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if preferRefresh {
if err := loadRefresh(ctx, conn); err != nil {
return err
}
if hasRefresh {
return nil
}
return loadAccess(ctx, conn)
}
if err := loadAccess(ctx, conn); err != nil {
return err
}
if hasAccess {
return nil
}
return loadRefresh(ctx, conn)
},
); err != nil {
return nil, err
}
switch {
case hasAccess:
if now.After(accessToken.ExpiresAt) {
return nil, nil
}
return &IntrospectResult{
ClientID: accessToken.ClientID,
IdentityID: accessToken.IdentityID,
Scopes: accessToken.Scopes,
IssuedAt: accessToken.CreatedAt,
ExpiresAt: accessToken.ExpiresAt,
TokenType: tokenTypeBearer,
}, nil
case hasRefresh:
if refreshToken.RevokedAt != nil || now.After(refreshToken.ExpiresAt) {
return nil, nil
}
return &IntrospectResult{
ClientID: refreshToken.ClientID,
IdentityID: refreshToken.IdentityID,
Scopes: refreshToken.Scopes,
IssuedAt: refreshToken.CreatedAt,
ExpiresAt: refreshToken.ExpiresAt,
}, nil
default:
return nil, nil
}
}
func (s *Service) UserInfo(
ctx context.Context,
identityID gid.GID,
scopes coredata.OAuth2Scopes,
) (map[string]any, error) {
identity := &coredata.Identity{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := identity.LoadByID(ctx, conn, identityID); err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
claims := map[string]any{
"sub": identity.ID.String(),
}
for _, scope := range scopes {
switch scope {
case coredata.OAuth2ScopeEmail:
claims["email"] = identity.EmailAddress.String()
claims["email_verified"] = identity.EmailAddressVerified
case coredata.OAuth2ScopeProfile:
claims["name"] = identity.FullName
}
}
return claims, nil
}
func (s *Service) RevokeToken(
ctx context.Context,
clientID gid.GID,
tokenValue string,
tokenTypeHint *coredata.OAuth2TokenTypeHint,
) error {
if tokenValue == "" {
return nil
}
hashedValue := hash.SHA256String(tokenValue)
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if tokenTypeHint != nil && *tokenTypeHint == coredata.OAuth2TokenTypeHintRefreshToken {
refreshToken := coredata.OAuth2RefreshToken{}
err := refreshToken.LoadByHashedValueAndClientID(ctx, tx, hashedValue, clientID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load refresh token: %w", err)
}
if err == nil {
now := time.Now()
if err := refreshToken.Revoke(ctx, tx, now); err != nil {
return fmt.Errorf("cannot revoke refresh token: %w", err)
}
if refreshToken.AccessTokenID != gid.Nil {
at := coredata.OAuth2AccessToken{ID: refreshToken.AccessTokenID}
if err := at.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete linked access token: %w", err)
}
}
return nil
}
accessToken := coredata.OAuth2AccessToken{}
err = accessToken.LoadByHashedValueAndClientID(ctx, tx, hashedValue, clientID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load access token: %w", err)
}
if err == nil {
if err := accessToken.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete access token: %w", err)
}
}
return nil
}
accessToken := coredata.OAuth2AccessToken{}
err := accessToken.LoadByHashedValueAndClientID(ctx, tx, hashedValue, clientID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load access token: %w", err)
}
if err == nil {
if err := accessToken.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete access token: %w", err)
}
return nil
}
refreshToken := coredata.OAuth2RefreshToken{}
err = refreshToken.LoadByHashedValueAndClientID(ctx, tx, hashedValue, clientID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load refresh token: %w", err)
}
if err == nil {
now := time.Now()
if err := refreshToken.Revoke(ctx, tx, now); err != nil {
return fmt.Errorf("cannot revoke refresh token: %w", err)
}
if refreshToken.AccessTokenID != gid.Nil {
at := coredata.OAuth2AccessToken{ID: refreshToken.AccessTokenID}
if err := at.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete linked access token: %w", err)
}
}
}
return nil
},
)
}
func (s *Service) Authorize(
ctx context.Context,
req *AuthorizeRequest,
) (string, error) {
var code string
if err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var client coredata.OAuth2Client
if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), req.ClientID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrClientNotFound
}
return fmt.Errorf("cannot load client: %w", err)
}
if !client.IsRedirectURIAllowed(req.RedirectURI) {
return ErrInvalidRedirectURI
}
if client.Visibility == coredata.OAuth2ClientVisibilityPrivate {
if client.OrganizationID == nil {
return fmt.Errorf("cannot authorize: private client has no organization")
}
var membership coredata.Membership
if err := membership.LoadActiveByIdentityIDAndOrganizationID(
ctx,
tx,
req.IdentityID,
*client.OrganizationID,
); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrUnauthorizedMember
}
return fmt.Errorf("cannot check membership: %w", err)
}
}
if req.ResponseType != coredata.OAuth2ResponseTypeCode {
return fmt.Errorf("cannot authorize: unsupported response_type")
}
requestedScopes := req.Scopes.OrDefault(client.Scopes)
if !client.AreScopesAllowed(requestedScopes) {
return fmt.Errorf("cannot authorize: requested scope exceeds client registration")
}
if requestedScopes.Contains(coredata.OAuth2ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) {
return NewError(
ErrInvalidScope,
WithDescription("offline_access requires the refresh_token grant type"),
)
}
codeChallengeMethod := req.CodeChallengeMethod
if client.TokenEndpointAuthMethod == coredata.OAuth2ClientTokenEndpointAuthMethodNone && req.CodeChallenge == "" {
return fmt.Errorf("cannot authorize: code_challenge required for public clients")
}
if codeChallengeMethod != "" && codeChallengeMethod != coredata.OAuth2CodeChallengeMethodS256 {
return fmt.Errorf("cannot authorize: only S256 code_challenge_method is supported")
}
if req.CodeChallenge != "" && codeChallengeMethod == "" {
codeChallengeMethod = coredata.OAuth2CodeChallengeMethodS256
}
// RFC 6819 §5.2.3.2 / §5.2.4.1: public clients must always require
// explicit user consent since they cannot be strongly authenticated.
if client.TokenEndpointAuthMethod != coredata.OAuth2ClientTokenEndpointAuthMethodNone {
var existingConsent coredata.OAuth2Consent
if err := existingConsent.LoadMatchingConsent(
ctx,
tx,
req.IdentityID,
client.ID,
requestedScopes,
); err == nil {
var err error
code, err = s.issueAuthorizationCode(
ctx,
tx,
&client,
req.IdentityID,
uri.URI(req.RedirectURI),
requestedScopes,
req.CodeChallenge,
codeChallengeMethod,
req.Nonce,
req.AuthTime,
)
if err != nil {
return fmt.Errorf("cannot issue authorization code: %w", err)
}
return nil
}
}
now := time.Now()
pendingConsent := &coredata.OAuth2Consent{
ID: gid.New(client.ID.TenantID(), coredata.OAuth2ConsentEntityType),
IdentityID: req.IdentityID,
SessionID: req.SessionID,
ClientID: client.ID,
Scopes: requestedScopes,
RedirectURI: new(uri.URI(req.RedirectURI)),
CodeChallenge: req.CodeChallenge,
CodeChallengeMethod: codeChallengeMethod,
Nonce: req.Nonce,
State: req.State,
Approved: false,
CreatedAt: now,
UpdatedAt: now,
}
if err := pendingConsent.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot create pending consent: %w", err)
}
return pg.NoRollback(
&ConsentRequiredError{
ConsentID: pendingConsent.ID,
Client: &client,
Scopes: requestedScopes,
},
)
},
); err != nil {
if _, ok := errors.AsType[*ConsentRequiredError](err); ok {
return "", err
}
return "", err
}
return code, nil
}
func (s *Service) GetConsentByID(
ctx context.Context,
consentID gid.GID,
) (*coredata.OAuth2Consent, error) {
var consent coredata.OAuth2Consent
if err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := consent.LoadByID(ctx, conn, consentID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return NewError(ErrInvalidRequest, WithDescription("consent not found"))
}
return fmt.Errorf("cannot load consent: %w", err)
}
return nil
},
); err != nil {
return nil, err
}
if consent.Approved {
return nil, NewError(
ErrInvalidRequest,
WithDescription("consent already processed"),
)
}
return &consent, nil
}
type ConsentApprovalResult struct {
// Authorization code flow fields.
Code string
RedirectURI string
State string
// Device flow: true when the consent was for a device code grant.
IsDeviceFlow bool
// Denied is true when the user denied the consent request.
Denied bool
}
func (s *Service) ApproveConsent(
ctx context.Context,
req *ConsentApprovalRequest,
) (*ConsentApprovalResult, error) {
var (
consent coredata.OAuth2Consent
result ConsentApprovalResult
)
if err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := consent.LoadByIDForSessionForUpdate(ctx, tx, req.ConsentID, req.IdentityID, req.SessionID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrConsentNotFound
}
return fmt.Errorf("cannot load consent: %w", err)
}
if consent.Approved {
return NewError(
ErrInvalidRequest,
WithDescription("consent already processed"),
)
}
var client coredata.OAuth2Client
if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), consent.ClientID); err != nil {
return fmt.Errorf("cannot load client: %w", err)
}
isDeviceFlow := consent.DeviceCodeID != nil
redirectURI := string(ref.UnrefOrZero(consent.RedirectURI))
if !isDeviceFlow && !client.IsRedirectURIAllowed(redirectURI) {
return ErrInvalidRedirectURI
}
var deviceCode coredata.OAuth2DeviceCode
if isDeviceFlow {
if err := deviceCode.LoadByIDForUpdate(ctx, tx, *consent.DeviceCodeID); err != nil {
return fmt.Errorf("cannot load device code: %w", err)
}
}
if !req.Approved {
if isDeviceFlow {
deviceCode.Status = coredata.OAuth2DeviceCodeStatusDenied
deviceCode.IdentityID = &consent.IdentityID
if err := deviceCode.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot deny device code: %w", err)
}
}
if err := consent.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete consent: %w", err)
}
result.Denied = true
result.IsDeviceFlow = isDeviceFlow
result.RedirectURI = redirectURI
result.State = consent.State
return nil
}
consent.Approved = true
consent.UpdatedAt = time.Now()
if err := consent.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot approve consent: %w", err)
}
if isDeviceFlow {
if deviceCode.Status != coredata.OAuth2DeviceCodeStatusPending {
return ErrDeviceCodeNotPending
}
deviceCode.Status = coredata.OAuth2DeviceCodeStatusAuthorized
deviceCode.IdentityID = &consent.IdentityID
if err := deviceCode.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update device code: %w", err)
}
result.IsDeviceFlow = true
return nil
}
code, err := s.issueAuthorizationCode(
ctx,
tx,
&client,
consent.IdentityID,
ref.UnrefOrZero(consent.RedirectURI),
consent.Scopes,
consent.CodeChallenge,
consent.CodeChallengeMethod,
consent.Nonce,
req.AuthTime,
)
if err != nil {
return fmt.Errorf("cannot issue authorization code: %w", err)
}
result.Code = code
result.RedirectURI = redirectURI
result.State = consent.State
return nil
},
); err != nil {
return nil, err
}
return &result, nil
}
func (s *Service) AuthenticateClient(
ctx context.Context,
clientID gid.GID,
clientSecret string,
) (*coredata.OAuth2Client, error) {
client, err := s.GetClientByID(ctx, clientID)
if err != nil {
return nil, NewError(ErrInvalidClient, WithDescription("cannot load client"))
}
if client.TokenEndpointAuthMethod == coredata.OAuth2ClientTokenEndpointAuthMethodNone {
return client, nil
}
if clientSecret == "" {
return nil, NewError(ErrInvalidClient, WithDescription("missing client_secret"))
}
if subtle.ConstantTimeCompare(client.ClientSecretHash, hash.SHA256String(clientSecret)) != 1 {
return nil, NewError(ErrInvalidClient, WithDescription("invalid client_secret"))
}
return client, nil
}
func (s *Service) issueAuthorizationCode(
ctx context.Context,
tx pg.Tx,
client *coredata.OAuth2Client,
identityID gid.GID,
redirectURI uri.URI,
scopes coredata.OAuth2Scopes,
codeChallenge string,
codeChallengeMethod coredata.OAuth2CodeChallengeMethod,
nonce string,
authTime time.Time,
) (string, error) {
codeValue := rand.MustHexString(tokenByteLength)
now := time.Now()
code := &coredata.OAuth2AuthorizationCode{
ID: gid.New(client.ID.TenantID(), coredata.OAuth2AuthorizationCodeEntityType),
HashedValue: hash.SHA256String(codeValue),
ClientID: client.ID,
IdentityID: identityID,
RedirectURI: redirectURI,
Scopes: scopes,
AuthTime: authTime,
CreatedAt: now,
ExpiresAt: now.Add(s.authorizationCodeDuration),
}
if codeChallenge != "" {
code.CodeChallenge = &codeChallenge
code.CodeChallengeMethod = &codeChallengeMethod
}
if nonce != "" {
code.Nonce = &nonce
}
if err := code.Insert(ctx, tx); err != nil {
return "", fmt.Errorf("cannot insert authorization code: %w", err)
}
return codeValue, nil
}