158 lines
4.8 KiB
Go
158 lines
4.8 KiB
Go
// Package auth implements the browser login flow against an OpenID Connect
|
|
// issuer: authorization code + PKCE (S256), discovery-driven endpoints, and
|
|
// RS256 ID token verification via JWKS. The signed-in identity is kept in an
|
|
// HMAC-protected session cookie (cookie.go) — tokens never reach the browser.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// txTTL bounds the login transaction: the gap between redirecting to the
|
|
// issuer and the callback that completes it.
|
|
const txTTL = 10 * time.Minute
|
|
|
|
// Config identifies this app to the issuer. Scopes defaults to
|
|
// ["openid","profile","email"] when empty.
|
|
type Config struct {
|
|
Issuer string
|
|
ClientID string
|
|
ClientSecret string
|
|
Scopes []string
|
|
}
|
|
|
|
// Client is the configured OIDC relying party. Discovery happens once in
|
|
// New; the client is immutable and safe for concurrent use.
|
|
type Client struct {
|
|
oauth oauth2.Config
|
|
verifier *oidc.IDTokenVerifier
|
|
}
|
|
|
|
// New discovers the issuer's endpoints and returns a ready client.
|
|
func New(ctx context.Context, cfg Config) (*Client, error) {
|
|
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("discover issuer %s: %w", cfg.Issuer, err)
|
|
}
|
|
if len(cfg.Scopes) == 0 {
|
|
cfg.Scopes = []string{"openid", "profile", "email"}
|
|
}
|
|
return &Client{
|
|
oauth: oauth2.Config{
|
|
ClientID: cfg.ClientID,
|
|
ClientSecret: cfg.ClientSecret,
|
|
Endpoint: provider.Endpoint(),
|
|
Scopes: cfg.Scopes,
|
|
},
|
|
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
|
}, nil
|
|
}
|
|
|
|
// User is the verified identity established by a completed login.
|
|
type User struct {
|
|
Sub string
|
|
Name string
|
|
Email string
|
|
Roles []string
|
|
}
|
|
|
|
// LoginTx is the per-login state kept in a short-lived cookie between the
|
|
// redirect to the issuer and the callback: state and nonce guard against
|
|
// CSRF and replay, the verifier carries PKCE, ReturnTo is where the browser
|
|
// lands afterwards.
|
|
type LoginTx struct {
|
|
State string
|
|
Nonce string
|
|
Verifier string
|
|
ReturnTo string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// NewLoginTx creates a login transaction with fresh state, nonce, and PKCE
|
|
// verifier for the given post-login path.
|
|
func NewLoginTx(returnTo string) (LoginTx, error) {
|
|
state, err := randomToken(16)
|
|
if err != nil {
|
|
return LoginTx{}, err
|
|
}
|
|
nonce, err := randomToken(16)
|
|
if err != nil {
|
|
return LoginTx{}, err
|
|
}
|
|
verifier, err := randomToken(48)
|
|
if err != nil {
|
|
return LoginTx{}, err
|
|
}
|
|
return LoginTx{
|
|
State: state,
|
|
Nonce: nonce,
|
|
Verifier: verifier,
|
|
ReturnTo: returnTo,
|
|
ExpiresAt: time.Now().Add(txTTL),
|
|
}, nil
|
|
}
|
|
|
|
// Valid reports whether the transaction is still within its TTL.
|
|
func (tx LoginTx) Valid() bool { return time.Now().Before(tx.ExpiresAt) }
|
|
|
|
// AuthCodeURL builds the issuer's authorization URL for tx — PKCE challenge
|
|
// and nonce included. redirectURI is the callback URL on the origin the
|
|
// browser came from (it must be registered with the issuer).
|
|
func (c *Client) AuthCodeURL(tx LoginTx, redirectURI string) string {
|
|
return c.oauth.AuthCodeURL(tx.State,
|
|
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
|
|
oauth2.SetAuthURLParam("nonce", tx.Nonce),
|
|
oauth2.S256ChallengeOption(tx.Verifier),
|
|
)
|
|
}
|
|
|
|
// CompleteCode exchanges the authorization code (PKCE verifier from tx) and
|
|
// verifies the returned ID token — signature via JWKS, plus iss/aud/exp and
|
|
// the nonce — returning the signed-in user.
|
|
func (c *Client) CompleteCode(ctx context.Context, code, redirectURI string, tx LoginTx) (User, error) {
|
|
tok, err := c.oauth.Exchange(ctx, code,
|
|
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
|
|
oauth2.VerifierOption(tx.Verifier),
|
|
)
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("exchange authorization code: %w", err)
|
|
}
|
|
rawIDToken, ok := tok.Extra("id_token").(string)
|
|
if !ok || rawIDToken == "" {
|
|
return User{}, errors.New("token response carries no id_token")
|
|
}
|
|
idToken, err := c.verifier.Verify(ctx, rawIDToken)
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("verify id_token: %w", err)
|
|
}
|
|
if idToken.Nonce != tx.Nonce {
|
|
return User{}, errors.New("id_token nonce does not match the login transaction")
|
|
}
|
|
var claims struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Roles []string `json:"roles"`
|
|
}
|
|
if err := idToken.Claims(&claims); err != nil {
|
|
return User{}, fmt.Errorf("decode id_token claims: %w", err)
|
|
}
|
|
return User{Sub: idToken.Subject, Name: claims.Name, Email: claims.Email, Roles: claims.Roles}, nil
|
|
}
|
|
|
|
// randomToken returns a URL-safe random string with n bytes of entropy.
|
|
func randomToken(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("read randomness: %w", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|