Squeamish about New()'s empty-domain-string sentinel for "dev mode, skip OIDC discovery" - split into New (always makes a real OIDC discovery call, all params required) and NewDev (no ctx/domain/credentials at all, since none are used). main.go now branches on cfg.DevAuthEnabled to pick the right constructor instead of main.go/config.go coordinating on when it's safe to pass empty strings. Also finishes out the dev-auth flow this enables: config.Load reads a DEV_AUTH_ENABLED-aware env file and only requires Auth0 vars when dev auth is off; a PORT config var replaces the hardcoded :8082; and the nav UI (layout/index templates, ui router) points login/logout links at /api/auth/dev-login and a new /api/auth/dev-logout route when dev auth is enabled, so the whole login/logout loop works locally without a real Auth0 app. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
416 lines
11 KiB
Go
416 lines
11 KiB
Go
package authentication
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/oauth2"
|
|
|
|
"ruben/inventory2/consts"
|
|
"ruben/inventory2/logging"
|
|
)
|
|
|
|
type (
|
|
// Authenticator is used to authenticate our users.
|
|
Authenticator struct {
|
|
log *logging.Logger
|
|
domain string
|
|
*oidc.Provider
|
|
oauth2.Config
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
// AccessTokenClaims is the claims Auth0 provides in access tokens
|
|
AccessTokenClaims struct {
|
|
Audience string `json:"aud"` // TODO: fill in from db
|
|
Expires int64 `json:"exp"`
|
|
Expiration time.Time `json:"-"` // parsed Expires
|
|
FamilyName string `json:"family_name"`
|
|
GivenName string `json:"given_name"`
|
|
IssuedAt int64 `json:"iat"` // TODO: fill in from db
|
|
Issuer string `json:"iss"` // TODO: fill in from db
|
|
Name string `json:"name"`
|
|
Nickname string `json:"nickname"`
|
|
Picture string `json:"picture"`
|
|
SessionID string `json:"sid"` // TODO: fill in from db
|
|
Subject string `json:"sub"` // TODO: fill in from db
|
|
UpdatedAt time.Time `json:"updated_at"` // TODO: fill in from db
|
|
}
|
|
)
|
|
|
|
// New instantiates an *Authenticator backed by a real Auth0 tenant: it makes
|
|
// an OIDC discovery call against domain, so login/callback/logout are fully
|
|
// functional. Use NewDev instead when DEV_AUTH_ENABLED is set and no real
|
|
// Auth0 app is configured.
|
|
func New(
|
|
ctx context.Context,
|
|
db *pgxpool.Pool,
|
|
logger *logging.Logger,
|
|
domain, clientID, clientSecret, callbackURL string,
|
|
) (*Authenticator, error) {
|
|
provider, err := oidc.NewProvider(
|
|
ctx,
|
|
"https://"+domain+"/",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Authenticator{
|
|
log: logger,
|
|
domain: domain,
|
|
Provider: provider,
|
|
Config: oauth2.Config{
|
|
ClientID: clientID,
|
|
ClientSecret: clientSecret,
|
|
RedirectURL: callbackURL,
|
|
Endpoint: provider.Endpoint(),
|
|
Scopes: []string{oidc.ScopeOpenID, "profile"},
|
|
},
|
|
db: db,
|
|
}, nil
|
|
}
|
|
|
|
// NewDev instantiates an *Authenticator with no real Auth0 tenant behind it:
|
|
// no OIDC discovery call is made, and Provider/Config are left zero-valued.
|
|
// Only DevLogin is safe to call on the result - Exchange, VerifyIDToken, and
|
|
// GetLogoutURL all assume a real Auth0 setup and will misbehave. Only use
|
|
// this from a route gated on an explicit dev-mode flag.
|
|
func NewDev(db *pgxpool.Pool, logger *logging.Logger) *Authenticator {
|
|
return &Authenticator{
|
|
log: logger,
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error {
|
|
for {
|
|
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil {
|
|
return fmt.Errorf("failed to delete all oauth_tokens rows that are expired: %w", err)
|
|
}
|
|
|
|
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_login_states WHERE expiration < NOW()"); err != nil {
|
|
return fmt.Errorf("failed to delete all oauth_login_states rows that are expired: %w", err)
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-time.After(time.Minute):
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exchange exchanges an auth code for an access token.
|
|
func (a *Authenticator) Exchange(ctx context.Context, state, code string) (accessToken, targetURI string, expiration time.Time, err error) {
|
|
// validate state
|
|
|
|
exp, targetURI, err := a.GetStateExpirationAndURL(ctx, state)
|
|
if errors.Is(err, consts.ErrNotFound) {
|
|
return "", "", time.Time{}, fmt.Errorf("invalid state: %w", consts.ErrNotFound)
|
|
} else if err != nil {
|
|
return "", "", time.Time{}, fmt.Errorf("failed to load state expiration: %w", err)
|
|
} else if exp.Before(time.Now()) {
|
|
return "", "", time.Time{}, fmt.Errorf("invalid state: state expired")
|
|
}
|
|
|
|
// obtain token and profile
|
|
|
|
tkn, err := a.Config.Exchange(ctx, code)
|
|
if err != nil {
|
|
return "", "", time.Time{}, fmt.Errorf("failed to exchange an authorization code for a token: %w", err)
|
|
}
|
|
|
|
idToken, claims, err := a.verifyIDTokenAndClaimsFromToken(ctx, tkn)
|
|
if err != nil {
|
|
return "", "", time.Time{}, err
|
|
}
|
|
|
|
// store the token, and the potentially new user
|
|
|
|
if _, err := a.db.Exec(
|
|
ctx,
|
|
`
|
|
WITH new_user AS (
|
|
INSERT INTO oauth_users (
|
|
user_id
|
|
)
|
|
VALUES (
|
|
@id_token_subject
|
|
)
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING
|
|
user_id
|
|
), the_user AS (
|
|
SELECT
|
|
COALESCE(user_id, user_id_2) as user_id
|
|
FROM
|
|
new_user
|
|
RIGHT JOIN
|
|
(SELECT @id_token_subject as user_id_2)
|
|
ON TRUE
|
|
)
|
|
INSERT INTO oauth_tokens (
|
|
access_token,
|
|
token_type,
|
|
refresh_token,
|
|
expiry,
|
|
|
|
id_token_issuer,
|
|
id_token_audience,
|
|
id_token_subject,
|
|
id_token_expiry,
|
|
id_token_issued_at,
|
|
id_token_nonce,
|
|
id_token_access_token_hash,
|
|
|
|
id_token_custom_claims_family_name,
|
|
id_token_custom_claims_given_name,
|
|
id_token_custom_claims_name,
|
|
id_token_custom_claims_nickname,
|
|
id_token_custom_claims_picture,
|
|
id_token_custom_claims_updated_at
|
|
)
|
|
SELECT
|
|
@access_token,
|
|
@token_type,
|
|
@refresh_token,
|
|
@expiry,
|
|
|
|
@id_token_issuer,
|
|
@id_token_audience,
|
|
user_id,
|
|
@id_token_expiry,
|
|
@id_token_issued_at,
|
|
@id_token_nonce,
|
|
@id_token_access_token_hash,
|
|
|
|
@id_token_custom_claims_family_name,
|
|
@id_token_custom_claims_given_name,
|
|
@id_token_custom_claims_name,
|
|
@id_token_custom_claims_nickname,
|
|
@id_token_custom_claims_picture,
|
|
@id_token_custom_claims_updated_at
|
|
FROM
|
|
the_user
|
|
`,
|
|
pgx.NamedArgs{
|
|
"access_token": tkn.AccessToken,
|
|
"token_type": tkn.TokenType,
|
|
"refresh_token": tkn.RefreshToken,
|
|
"expiry": tkn.Expiry,
|
|
|
|
"id_token_issuer": idToken.Issuer,
|
|
"id_token_audience": pgtype.FlatArray[string](idToken.Audience),
|
|
"id_token_subject": idToken.Subject,
|
|
"id_token_expiry": idToken.Expiry,
|
|
"id_token_issued_at": idToken.IssuedAt,
|
|
"id_token_nonce": idToken.Nonce,
|
|
"id_token_access_token_hash": idToken.AccessTokenHash,
|
|
|
|
"id_token_custom_claims_family_name": claims.FamilyName,
|
|
"id_token_custom_claims_given_name": claims.GivenName,
|
|
"id_token_custom_claims_name": claims.Name,
|
|
"id_token_custom_claims_nickname": claims.Nickname,
|
|
"id_token_custom_claims_picture": claims.Picture,
|
|
"id_token_custom_claims_updated_at": claims.UpdatedAt,
|
|
},
|
|
); err != nil {
|
|
return "", "", time.Time{}, fmt.Errorf("failed to perform query to save tokens: %w", err)
|
|
}
|
|
|
|
return tkn.AccessToken, targetURI, tkn.Expiry.UTC(), nil
|
|
}
|
|
|
|
// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
|
|
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
|
|
rawIDToken, ok := token.Extra("id_token").(string)
|
|
if !ok {
|
|
return nil, errors.New("no id_token field in oauth2 token")
|
|
}
|
|
|
|
oidcConfig := &oidc.Config{
|
|
ClientID: a.ClientID,
|
|
}
|
|
|
|
return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
|
|
}
|
|
|
|
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
|
|
return &url.URL{
|
|
Scheme: "https",
|
|
Host: a.domain,
|
|
Path: "/v2/logout",
|
|
RawQuery: url.Values{
|
|
"returnTo": {
|
|
(&url.URL{
|
|
Scheme: "https",
|
|
Host: requestHost,
|
|
}).String(),
|
|
},
|
|
"client_id": {a.Config.ClientID},
|
|
}.Encode(),
|
|
}
|
|
}
|
|
|
|
func (a *Authenticator) RefreshAccessToken(
|
|
ctx context.Context,
|
|
oldAccessToken string,
|
|
) (
|
|
accessToken string,
|
|
expiration time.Time,
|
|
err error,
|
|
) {
|
|
|
|
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, oldAccessToken)
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err)
|
|
}
|
|
|
|
tkn, err := a.TokenSource(ctx, &oauth2.Token{
|
|
// AccessToken is the token that authorizes and authenticates
|
|
// the requests.
|
|
AccessToken: oldAccessToken,
|
|
|
|
// TokenType is the type of token.
|
|
// The Type method returns either this or "Bearer", the default.
|
|
TokenType: tokenType,
|
|
|
|
// RefreshToken is a token that's used by the application
|
|
// (as opposed to the user) to refresh the access token
|
|
// if it expires.
|
|
RefreshToken: refreshToken,
|
|
|
|
/*
|
|
// Expiry is the optional expiration time of the access token.
|
|
//
|
|
// If zero, TokenSource implementations will reuse the same
|
|
// token forever and RefreshToken or equivalent
|
|
// mechanisms for that TokenSource will not be used.
|
|
Expiry time.Time `json:"expiry,omitempty"`
|
|
*/
|
|
}).Token()
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("failed to fetch refresh token: %w", err)
|
|
}
|
|
|
|
idToken, claims, err := a.verifyIDTokenAndClaimsFromToken(ctx, tkn)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
|
|
if _, err = a.db.Exec(
|
|
ctx,
|
|
`
|
|
WITH deleted_tokens AS (
|
|
DELETE FROM
|
|
oauth_tokens
|
|
WHERE
|
|
access_token = @old_access_token
|
|
RETURNING
|
|
access_token AS old_access_token
|
|
), new_tokens AS (
|
|
INSERT INTO oauth_tokens (
|
|
access_token,
|
|
token_type,
|
|
refresh_token,
|
|
expiry,
|
|
|
|
id_token_issuer,
|
|
id_token_audience,
|
|
id_token_subject,
|
|
id_token_expiry,
|
|
id_token_issued_at,
|
|
id_token_nonce,
|
|
id_token_access_token_hash,
|
|
|
|
id_token_custom_claims_family_name,
|
|
id_token_custom_claims_given_name,
|
|
id_token_custom_claims_name,
|
|
id_token_custom_claims_nickname,
|
|
id_token_custom_claims_picture,
|
|
id_token_custom_claims_updated_at
|
|
)
|
|
VALUES (
|
|
@new_access_token,
|
|
@token_type,
|
|
@refresh_token,
|
|
@expiry,
|
|
|
|
@id_token_issuer,
|
|
@id_token_audience,
|
|
@id_token_subject,
|
|
@id_token_expiry,
|
|
@id_token_issued_at,
|
|
@id_token_nonce,
|
|
@id_token_access_token_hash,
|
|
|
|
@id_token_custom_claims_family_name,
|
|
@id_token_custom_claims_given_name,
|
|
@id_token_custom_claims_name,
|
|
@id_token_custom_claims_nickname,
|
|
@id_token_custom_claims_picture,
|
|
@id_token_custom_claims_updated_at
|
|
)
|
|
RETURNING
|
|
access_token AS new_access_token
|
|
)
|
|
SELECT
|
|
old_access_token,
|
|
new_access_token
|
|
FROM
|
|
deleted_tokens
|
|
FULL JOIN
|
|
new_tokens
|
|
`,
|
|
pgx.NamedArgs{
|
|
"old_access_token": oldAccessToken,
|
|
|
|
"new_access_token": tkn.AccessToken,
|
|
"token_type": tkn.TokenType,
|
|
"refresh_token": tkn.RefreshToken,
|
|
"expiry": tkn.Expiry,
|
|
|
|
"id_token_issuer": idToken.Issuer,
|
|
"id_token_audience": pgtype.FlatArray[string](idToken.Audience),
|
|
"id_token_subject": idToken.Subject,
|
|
"id_token_expiry": idToken.Expiry,
|
|
"id_token_issued_at": idToken.IssuedAt,
|
|
"id_token_nonce": idToken.Nonce,
|
|
"id_token_access_token_hash": idToken.AccessTokenHash,
|
|
|
|
"id_token_custom_claims_family_name": claims.FamilyName,
|
|
"id_token_custom_claims_given_name": claims.GivenName,
|
|
"id_token_custom_claims_name": claims.Name,
|
|
"id_token_custom_claims_nickname": claims.Nickname,
|
|
"id_token_custom_claims_picture": claims.Picture,
|
|
"id_token_custom_claims_updated_at": claims.UpdatedAt,
|
|
},
|
|
); err != nil {
|
|
return "", time.Time{}, fmt.Errorf("failed to save new access token and delete old access token: %w", err)
|
|
}
|
|
|
|
return tkn.AccessToken, tkn.Expiry.UTC(), nil
|
|
}
|
|
|
|
func (a *Authenticator) verifyIDTokenAndClaimsFromToken(ctx context.Context, tkn *oauth2.Token) (idToken *oidc.IDToken, claims *AccessTokenClaims, err error) {
|
|
if idToken, err = a.VerifyIDToken(ctx, tkn); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to verify id Token: %w", err)
|
|
}
|
|
|
|
claims = new(AccessTokenClaims)
|
|
|
|
if err := idToken.Claims(claims); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to obtain id token claims: %w", err)
|
|
}
|
|
|
|
return idToken, claims, nil
|
|
}
|