Native OAuth clients such as Claude Code publish loopback redirect URIs without a port (http://localhost/callback) and pick an ephemeral port at request time, as described in RFC 8252 section 7.3. The authorize flow matched the requested redirect URI against the registered set with an exact string comparison, so http://localhost:3118/callback was rejected with invalid_redirect_uri even for a trusted, allow-listed client. Make OAuth2Client.IsRedirectURIAllowed the single source of truth for redirect matching: it keeps exact matching and adds loopback-aware matching that ignores the port when scheme, host, path, and query agree. The redundant document-level check and its duplicate loopback helper in the CIMD resolver are removed, so both the registered-client and CIMD paths now rely on one matcher. Also add a pkg/netx package for the loopback helper. Signed-off-by: Bryan Frimin <bryan@probo.com>
1951 lines
50 KiB
Go
1951 lines
50 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.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 oauth2
|
|
|
|
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/iam/oauth2scope"
|
|
"go.probo.inc/probo/pkg/netx"
|
|
"go.probo.inc/probo/pkg/page"
|
|
"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"
|
|
oauthGrantAccessTokenName = "OAuth grant"
|
|
|
|
// 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
|
|
cimd *cimdFetcher
|
|
cimdAllowedClientIDs []string
|
|
registry *oauth2scope.Registry
|
|
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
|
|
ClientIDRaw string
|
|
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
|
|
}
|
|
|
|
CreateManualAccessTokenRequest struct {
|
|
IdentityID gid.GID
|
|
Name string
|
|
ExpiresAt time.Time
|
|
Scopes coredata.OAuth2Scopes
|
|
}
|
|
)
|
|
|
|
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 WithRegistry(registry *oauth2scope.Registry) Option {
|
|
return func(s *Service) {
|
|
s.registry = registry
|
|
}
|
|
}
|
|
|
|
func WithCIMDAllowedClientIDs(clientIDs []string) Option {
|
|
return func(s *Service) {
|
|
s.cimdAllowedClientIDs = clientIDs
|
|
}
|
|
}
|
|
|
|
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)
|
|
s.cimd = newCIMDFetcher(logger.Named("cimd"))
|
|
|
|
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)
|
|
}
|
|
|
|
// 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),
|
|
Name: oauthGrantAccessTokenName,
|
|
HashedValue: hash.SHA256String(tokenValue),
|
|
ClientID: new(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(ScopeOpenID) {
|
|
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,
|
|
Name: oauthGrantAccessTokenName,
|
|
HashedValue: hash.SHA256String(accessTokenValue),
|
|
ClientID: new(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(ScopeOfflineAccess) {
|
|
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(ScopeOpenID) {
|
|
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),
|
|
Name: oauthGrantAccessTokenName,
|
|
HashedValue: hash.SHA256String(accessTokenValue),
|
|
ClientID: new(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(ScopeOfflineAccess) && !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(ScopeOpenID) {
|
|
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),
|
|
Name: oauthGrantAccessTokenName,
|
|
HashedValue: hash.SHA256String(accessTokenValue),
|
|
ClientID: new(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(ScopeOfflineAccess) {
|
|
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 !netx.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
|
|
}
|
|
|
|
var resultClientID gid.GID
|
|
if accessToken.ClientID != nil {
|
|
resultClientID = *accessToken.ClientID
|
|
}
|
|
|
|
return &IntrospectResult{
|
|
ClientID: resultClientID,
|
|
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 ScopeEmail:
|
|
claims["email"] = identity.EmailAddress.String()
|
|
claims["email_verified"] = identity.EmailAddressVerified
|
|
case ScopeProfile:
|
|
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 {
|
|
client, err := s.resolveClient(ctx, tx, req.ClientIDRaw)
|
|
if err != nil {
|
|
return 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(ScopeOfflineAccess) && !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,
|
|
clientIDRaw string,
|
|
clientSecret string,
|
|
) (*coredata.OAuth2Client, error) {
|
|
client, err := s.resolveClient(ctx, nil, clientIDRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (s *Service) GetAccessTokenByID(ctx context.Context, accessTokenID gid.GID) (*coredata.OAuth2AccessToken, error) {
|
|
token := &coredata.OAuth2AccessToken{}
|
|
|
|
err := s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
if err := token.LoadByID(ctx, conn, accessTokenID); err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return coredata.ErrResourceNotFound
|
|
}
|
|
|
|
return fmt.Errorf("cannot load oauth2 access token: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
func (s *Service) ListAccessTokensByIdentityID(
|
|
ctx context.Context,
|
|
identityID gid.GID,
|
|
cursor *page.Cursor[coredata.OAuth2AccessTokenOrderField],
|
|
) (*page.Page[*coredata.OAuth2AccessToken, coredata.OAuth2AccessTokenOrderField], error) {
|
|
var tokens coredata.OAuth2AccessTokens
|
|
|
|
err := s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
if err := tokens.LoadByIdentityID(ctx, conn, identityID, cursor); err != nil {
|
|
return fmt.Errorf("cannot load oauth2 access tokens: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return page.NewPage(tokens, cursor), nil
|
|
}
|
|
|
|
func (s *Service) CountAccessTokensByIdentityID(
|
|
ctx context.Context,
|
|
identityID gid.GID,
|
|
) (int, error) {
|
|
var count int
|
|
|
|
err := s.pg.WithConn(
|
|
ctx,
|
|
func(ctx context.Context, conn pg.Querier) error {
|
|
var tokens coredata.OAuth2AccessTokens
|
|
|
|
var err error
|
|
|
|
count, err = tokens.CountByIdentityID(ctx, conn, identityID)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot count oauth2 access tokens: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
func (s *Service) RevokeAccessToken(ctx context.Context, accessTokenID gid.GID) error {
|
|
return s.pg.WithTx(
|
|
ctx,
|
|
func(ctx context.Context, tx pg.Tx) error {
|
|
token := &coredata.OAuth2AccessToken{}
|
|
|
|
if err := token.LoadByID(ctx, tx, accessTokenID); err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("cannot load oauth2 access token: %w", err)
|
|
}
|
|
|
|
if err := token.Delete(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot revoke oauth2 access token: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func (s *Service) CreateManualAccessToken(
|
|
ctx context.Context,
|
|
req *CreateManualAccessTokenRequest,
|
|
) (string, *coredata.OAuth2AccessToken, error) {
|
|
if req.Name == "" {
|
|
return "", nil, NewError(ErrInvalidRequest, WithDescription("name is required"))
|
|
}
|
|
|
|
now := time.Now()
|
|
if !req.ExpiresAt.After(now) {
|
|
return "", nil, NewError(ErrInvalidRequest, WithDescription("expires_at must be in the future"))
|
|
}
|
|
|
|
if len(req.Scopes) == 0 {
|
|
return "", nil, NewError(ErrInvalidRequest, WithDescription("scopes are required"))
|
|
}
|
|
|
|
if err := s.registry.ValidateScopes(req.Scopes); err != nil {
|
|
return "", nil, NewError(ErrInvalidScope, WithDescription(err.Error()))
|
|
}
|
|
|
|
tokenValue := rand.MustHexString(tokenByteLength)
|
|
|
|
accessToken := &coredata.OAuth2AccessToken{
|
|
ID: gid.New(req.IdentityID.TenantID(), coredata.OAuth2AccessTokenEntityType),
|
|
Name: req.Name,
|
|
HashedValue: hash.SHA256String(tokenValue),
|
|
ClientID: nil,
|
|
IdentityID: req.IdentityID,
|
|
Scopes: req.Scopes,
|
|
CreatedAt: now,
|
|
ExpiresAt: req.ExpiresAt,
|
|
}
|
|
|
|
err := s.pg.WithTx(
|
|
ctx,
|
|
func(ctx context.Context, tx pg.Tx) error {
|
|
if err := accessToken.Insert(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot insert oauth2 access token: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return tokenValue, accessToken, nil
|
|
}
|