848 lines
24 KiB
Go
848 lines
24 KiB
Go
|
|
package auth
|
||
|
|
|
||
|
|
// Pocket-ID OIDC Authorization-Code middleware for admin-api.
|
||
|
|
//
|
||
|
|
// Flow:
|
||
|
|
// 1. Unauthenticated request to a protected path → 302 to /login?next=…
|
||
|
|
// 2. /login builds state + nonce + PKCE verifier, drops them in a short-lived
|
||
|
|
// encrypted "oidc_state" cookie, 302s to Pocket-ID's authorize endpoint.
|
||
|
|
// 3. Pocket-ID redirects back to /callback?code=…&state=… — we match state,
|
||
|
|
// exchange code at the token endpoint (with the PKCE verifier), parse the
|
||
|
|
// ID token, verify its RS256 signature against the cached JWKS, and
|
||
|
|
// enforce membership in RequiredGroup.
|
||
|
|
// 4. On success we drop an encrypted session cookie carrying {sub, sid, exp,
|
||
|
|
// groups} and 302 to the original "next" URL.
|
||
|
|
//
|
||
|
|
// Protect(h) wraps a handler so each request must present a valid session
|
||
|
|
// cookie; it decrypts the cookie and stamps WithPrincipal / WithSession on the
|
||
|
|
// request context. Missing/expired/tampered cookies send a 302 back to /login.
|
||
|
|
//
|
||
|
|
// The verifier is stdlib-only: `crypto/rsa` + `crypto/sha256` for RS256 JWS
|
||
|
|
// verification, `crypto/aes` + `crypto/cipher` (AES-GCM) for cookie sealing.
|
||
|
|
// OIDC discovery (`/.well-known/openid-configuration`) and the JWKS are
|
||
|
|
// fetched lazily and cached for DiscoveryTTL.
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto"
|
||
|
|
"crypto/aes"
|
||
|
|
"crypto/cipher"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/rsa"
|
||
|
|
"crypto/sha256"
|
||
|
|
"crypto/subtle"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/binary"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"log/slog"
|
||
|
|
"math/big"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Config configures the Pocket-ID middleware. All fields except *Client* and
|
||
|
|
// *Logger* and *Secure* are required for production use; the tests fill them
|
||
|
|
// against an httptest-mock Pocket-ID.
|
||
|
|
type Config struct {
|
||
|
|
// Issuer is the Pocket-ID base URL (e.g. "https://auth.basicstack.de").
|
||
|
|
// Discovery is loaded from Issuer + "/.well-known/openid-configuration".
|
||
|
|
Issuer string
|
||
|
|
// ClientID / ClientSecret identify the admin-api OIDC client registered
|
||
|
|
// in Pocket-ID. ClientSecret is sent via basic-auth to the token endpoint.
|
||
|
|
ClientID string
|
||
|
|
ClientSecret string
|
||
|
|
// RedirectURL must match the Pocket-ID client's registered callback and
|
||
|
|
// should resolve to the mounted CallbackPath on this service.
|
||
|
|
RedirectURL string
|
||
|
|
// RequiredGroup is the group a user MUST be in to pass the middleware.
|
||
|
|
// Default: "proofedmesh-admin".
|
||
|
|
RequiredGroup string
|
||
|
|
// CookieName is the browser session cookie name.
|
||
|
|
// Default: "proofedmesh_admin_session".
|
||
|
|
CookieName string
|
||
|
|
// StateCookieName is the transient OIDC-state cookie name.
|
||
|
|
// Default: "proofedmesh_admin_oidc_state".
|
||
|
|
StateCookieName string
|
||
|
|
// CookieSecret is a 32-byte AES-256 key used to seal cookies.
|
||
|
|
CookieSecret []byte
|
||
|
|
// SessionTTL bounds how long a session cookie is accepted.
|
||
|
|
// Default: 8h.
|
||
|
|
SessionTTL time.Duration
|
||
|
|
// StateTTL bounds how long the OIDC state cookie is accepted.
|
||
|
|
// Default: 10m.
|
||
|
|
StateTTL time.Duration
|
||
|
|
// DiscoveryTTL bounds how long OIDC discovery + JWKS stay cached before
|
||
|
|
// a background refresh. Default: 10m.
|
||
|
|
DiscoveryTTL time.Duration
|
||
|
|
// HTTPClient is used to call discovery / JWKS / token endpoints.
|
||
|
|
HTTPClient *http.Client
|
||
|
|
// Now is the clock. Default: time.Now.
|
||
|
|
Now func() time.Time
|
||
|
|
// Secure=true sets the Secure flag on cookies (prod); tests leave it false.
|
||
|
|
Secure bool
|
||
|
|
// LoginPath and CallbackPath are where Register() mounts the handlers.
|
||
|
|
// Defaults: /login and /callback.
|
||
|
|
LoginPath string
|
||
|
|
CallbackPath string
|
||
|
|
// DefaultNext is where a successful login redirects when no ?next was set.
|
||
|
|
// Default: "/admin/per-diem-rates/".
|
||
|
|
DefaultNext string
|
||
|
|
Logger *slog.Logger
|
||
|
|
}
|
||
|
|
|
||
|
|
// Middleware is the Pocket-ID verifier + handler bundle. Construct with New.
|
||
|
|
type Middleware struct {
|
||
|
|
cfg Config
|
||
|
|
|
||
|
|
aead cipher.AEAD
|
||
|
|
|
||
|
|
mu sync.Mutex
|
||
|
|
discovery discoveryDoc
|
||
|
|
jwks map[string]*rsa.PublicKey
|
||
|
|
lastRefresh time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type discoveryDoc struct {
|
||
|
|
Issuer string `json:"issuer"`
|
||
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||
|
|
TokenEndpoint string `json:"token_endpoint"`
|
||
|
|
JWKSURI string `json:"jwks_uri"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type jwk struct {
|
||
|
|
Kid string `json:"kid"`
|
||
|
|
Kty string `json:"kty"`
|
||
|
|
Alg string `json:"alg"`
|
||
|
|
Use string `json:"use"`
|
||
|
|
N string `json:"n"`
|
||
|
|
E string `json:"e"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type jwksDoc struct {
|
||
|
|
Keys []jwk `json:"keys"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Session is the payload sealed into the session cookie. Groups is retained so
|
||
|
|
// policy middlewares downstream can enforce finer-grained checks without a
|
||
|
|
// round-trip to Pocket-ID.
|
||
|
|
type Session struct {
|
||
|
|
Sub string `json:"sub"`
|
||
|
|
SID string `json:"sid"`
|
||
|
|
Groups []string `json:"grp,omitempty"`
|
||
|
|
ExpiresAt int64 `json:"exp"`
|
||
|
|
IssuedAt int64 `json:"iat"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// oidcState is the payload sealed into the short-lived state cookie between
|
||
|
|
// /login and /callback. It carries everything we need to validate the callback
|
||
|
|
// without server-side state.
|
||
|
|
type oidcState struct {
|
||
|
|
State string `json:"s"`
|
||
|
|
Nonce string `json:"n"`
|
||
|
|
CodeVerifier string `json:"v"`
|
||
|
|
Next string `json:"nx,omitempty"`
|
||
|
|
CreatedAt int64 `json:"iat"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// New constructs a Middleware. It validates required Config fields and
|
||
|
|
// derives the AEAD from CookieSecret. Discovery/JWKS are loaded lazily on the
|
||
|
|
// first request.
|
||
|
|
func New(cfg Config) (*Middleware, error) {
|
||
|
|
if cfg.Issuer == "" {
|
||
|
|
return nil, errors.New("auth: Issuer is required")
|
||
|
|
}
|
||
|
|
if cfg.ClientID == "" {
|
||
|
|
return nil, errors.New("auth: ClientID is required")
|
||
|
|
}
|
||
|
|
if cfg.ClientSecret == "" {
|
||
|
|
return nil, errors.New("auth: ClientSecret is required")
|
||
|
|
}
|
||
|
|
if cfg.RedirectURL == "" {
|
||
|
|
return nil, errors.New("auth: RedirectURL is required")
|
||
|
|
}
|
||
|
|
if len(cfg.CookieSecret) != 32 {
|
||
|
|
return nil, errors.New("auth: CookieSecret must be exactly 32 bytes (AES-256)")
|
||
|
|
}
|
||
|
|
if cfg.RequiredGroup == "" {
|
||
|
|
cfg.RequiredGroup = "proofedmesh-admin"
|
||
|
|
}
|
||
|
|
if cfg.CookieName == "" {
|
||
|
|
cfg.CookieName = "proofedmesh_admin_session"
|
||
|
|
}
|
||
|
|
if cfg.StateCookieName == "" {
|
||
|
|
cfg.StateCookieName = "proofedmesh_admin_oidc_state"
|
||
|
|
}
|
||
|
|
if cfg.SessionTTL == 0 {
|
||
|
|
cfg.SessionTTL = 8 * time.Hour
|
||
|
|
}
|
||
|
|
if cfg.StateTTL == 0 {
|
||
|
|
cfg.StateTTL = 10 * time.Minute
|
||
|
|
}
|
||
|
|
if cfg.DiscoveryTTL == 0 {
|
||
|
|
cfg.DiscoveryTTL = 10 * time.Minute
|
||
|
|
}
|
||
|
|
if cfg.HTTPClient == nil {
|
||
|
|
cfg.HTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||
|
|
}
|
||
|
|
if cfg.Now == nil {
|
||
|
|
cfg.Now = time.Now
|
||
|
|
}
|
||
|
|
if cfg.LoginPath == "" {
|
||
|
|
cfg.LoginPath = "/login"
|
||
|
|
}
|
||
|
|
if cfg.CallbackPath == "" {
|
||
|
|
cfg.CallbackPath = "/callback"
|
||
|
|
}
|
||
|
|
if cfg.DefaultNext == "" {
|
||
|
|
cfg.DefaultNext = "/admin/per-diem-rates/"
|
||
|
|
}
|
||
|
|
|
||
|
|
block, err := aes.NewCipher(cfg.CookieSecret)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("auth: cookie cipher: %w", err)
|
||
|
|
}
|
||
|
|
aead, err := cipher.NewGCM(block)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("auth: cookie AEAD: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &Middleware{cfg: cfg, aead: aead}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Register wires LoginPath + CallbackPath onto mux.
|
||
|
|
func (m *Middleware) Register(mux *http.ServeMux) {
|
||
|
|
mux.HandleFunc("GET "+m.cfg.LoginPath, m.handleLogin)
|
||
|
|
mux.HandleFunc("GET "+m.cfg.CallbackPath, m.handleCallback)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Protect wraps h so every request must present a valid session cookie that
|
||
|
|
// decrypts, has not expired, and whose groups include RequiredGroup. On any
|
||
|
|
// failure the response is a 302 to /login?next=<original path+query>.
|
||
|
|
func (m *Middleware) Protect(h http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
sess, err := m.sessionFromCookie(r)
|
||
|
|
if err != nil {
|
||
|
|
m.redirectToLogin(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !containsString(sess.Groups, m.cfg.RequiredGroup) {
|
||
|
|
// Session is otherwise valid but group membership has been
|
||
|
|
// revoked since login; force re-authentication rather than
|
||
|
|
// returning a stale 403.
|
||
|
|
m.redirectToLogin(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ctx := WithPrincipal(r.Context(), sess.Sub)
|
||
|
|
ctx = WithSession(ctx, sess.SID)
|
||
|
|
h.ServeHTTP(w, r.WithContext(ctx))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// SessionFromRequest exposes the authenticated session for callers that need
|
||
|
|
// the raw groups/claims (e.g. for logging). Returns nil + error on any
|
||
|
|
// validation failure.
|
||
|
|
func (m *Middleware) SessionFromRequest(r *http.Request) (*Session, error) {
|
||
|
|
return m.sessionFromCookie(r)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||
|
|
disc, _, err := m.ensureDiscovery(r.Context())
|
||
|
|
if err != nil {
|
||
|
|
m.logError("discovery failed", err)
|
||
|
|
http.Error(w, "auth backend unavailable", http.StatusBadGateway)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
state, err := randomURLString(24)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "state gen failed", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
nonce, err := randomURLString(24)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "nonce gen failed", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
codeVerifier, err := randomURLString(48)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "pkce gen failed", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sum := sha256.Sum256([]byte(codeVerifier))
|
||
|
|
codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||
|
|
|
||
|
|
next := sanitizeNext(r.URL.Query().Get("next"), m.cfg.DefaultNext)
|
||
|
|
|
||
|
|
stPayload := oidcState{
|
||
|
|
State: state,
|
||
|
|
Nonce: nonce,
|
||
|
|
CodeVerifier: codeVerifier,
|
||
|
|
Next: next,
|
||
|
|
CreatedAt: m.cfg.Now().Unix(),
|
||
|
|
}
|
||
|
|
raw, err := json.Marshal(stPayload)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "state marshal failed", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sealed, err := m.seal(raw)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "state seal failed", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
http.SetCookie(w, m.makeCookie(m.cfg.StateCookieName, sealed, int(m.cfg.StateTTL.Seconds())))
|
||
|
|
|
||
|
|
q := url.Values{
|
||
|
|
"client_id": {m.cfg.ClientID},
|
||
|
|
"redirect_uri": {m.cfg.RedirectURL},
|
||
|
|
"response_type": {"code"},
|
||
|
|
"scope": {"openid profile email groups"},
|
||
|
|
"state": {state},
|
||
|
|
"nonce": {nonce},
|
||
|
|
"code_challenge": {codeChallenge},
|
||
|
|
"code_challenge_method": {"S256"},
|
||
|
|
}
|
||
|
|
http.Redirect(w, r, disc.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||
|
|
q := r.URL.Query()
|
||
|
|
if errCode := q.Get("error"); errCode != "" {
|
||
|
|
http.Error(w, "oidc error: "+errCode, http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
stateParam := q.Get("state")
|
||
|
|
code := q.Get("code")
|
||
|
|
if stateParam == "" || code == "" {
|
||
|
|
http.Error(w, "missing state or code", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
ck, err := r.Cookie(m.cfg.StateCookieName)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "state cookie missing", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
raw, err := m.open(ck.Value)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "state cookie invalid", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var st oidcState
|
||
|
|
if err := json.Unmarshal(raw, &st); err != nil {
|
||
|
|
http.Error(w, "state cookie invalid", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if subtle.ConstantTimeCompare([]byte(stateParam), []byte(st.State)) != 1 {
|
||
|
|
http.Error(w, "state mismatch", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
now := m.cfg.Now()
|
||
|
|
if now.Unix()-st.CreatedAt > int64(m.cfg.StateTTL.Seconds()) {
|
||
|
|
http.Error(w, "state expired", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
// clear the state cookie now that it's been consumed
|
||
|
|
http.SetCookie(w, m.clearCookie(m.cfg.StateCookieName))
|
||
|
|
|
||
|
|
disc, keys, err := m.ensureDiscovery(r.Context())
|
||
|
|
if err != nil {
|
||
|
|
m.logError("discovery failed", err)
|
||
|
|
http.Error(w, "auth backend unavailable", http.StatusBadGateway)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
idToken, err := m.exchangeCode(r.Context(), disc.TokenEndpoint, code, st.CodeVerifier)
|
||
|
|
if err != nil {
|
||
|
|
m.logError("token exchange failed", err)
|
||
|
|
http.Error(w, "token exchange failed", http.StatusBadGateway)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
claims, err := verifyIDToken(idToken, keys, m.cfg.Issuer, m.cfg.ClientID, st.Nonce, now)
|
||
|
|
if err != nil {
|
||
|
|
m.logError("id_token verify failed", err)
|
||
|
|
http.Error(w, "id_token invalid", http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
groups := extractGroups(claims)
|
||
|
|
if !containsString(groups, m.cfg.RequiredGroup) {
|
||
|
|
http.Error(w, "forbidden: missing group "+m.cfg.RequiredGroup, http.StatusForbidden)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
sub, _ := claims["sub"].(string)
|
||
|
|
sid, _ := claims["sid"].(string)
|
||
|
|
if sub == "" {
|
||
|
|
http.Error(w, "id_token missing sub", http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if sid == "" {
|
||
|
|
// Pocket-ID emits `sid` for session binding, but a conforming OP MAY
|
||
|
|
// omit it. Synthesize a stable-enough id so attestations can group
|
||
|
|
// changes made under the same browser login.
|
||
|
|
sid = "local-" + randomStateFallback()
|
||
|
|
}
|
||
|
|
session := Session{
|
||
|
|
Sub: sub,
|
||
|
|
SID: sid,
|
||
|
|
Groups: groups,
|
||
|
|
IssuedAt: now.Unix(),
|
||
|
|
ExpiresAt: now.Add(m.cfg.SessionTTL).Unix(),
|
||
|
|
}
|
||
|
|
sessRaw, err := json.Marshal(session)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "session marshal failed", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sealed, err := m.seal(sessRaw)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "session seal failed", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
http.SetCookie(w, m.makeCookie(m.cfg.CookieName, sealed, int(m.cfg.SessionTTL.Seconds())))
|
||
|
|
|
||
|
|
next := sanitizeNext(st.Next, m.cfg.DefaultNext)
|
||
|
|
http.Redirect(w, r, next, http.StatusFound)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||
|
|
q := url.Values{}
|
||
|
|
nx := r.URL.RequestURI()
|
||
|
|
if nx != "" && nx != "/" {
|
||
|
|
q.Set("next", nx)
|
||
|
|
}
|
||
|
|
target := m.cfg.LoginPath
|
||
|
|
if len(q) > 0 {
|
||
|
|
target += "?" + q.Encode()
|
||
|
|
}
|
||
|
|
http.Redirect(w, r, target, http.StatusFound)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) sessionFromCookie(r *http.Request) (*Session, error) {
|
||
|
|
ck, err := r.Cookie(m.cfg.CookieName)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
raw, err := m.open(ck.Value)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var s Session
|
||
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if m.cfg.Now().Unix() >= s.ExpiresAt {
|
||
|
|
return nil, errors.New("session expired")
|
||
|
|
}
|
||
|
|
return &s, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// exchangeCode calls the OP's token endpoint with client credentials via basic
|
||
|
|
// auth, PKCE verifier, and the authorization code. Returns the raw ID token.
|
||
|
|
func (m *Middleware) exchangeCode(ctx context.Context, tokenEndpoint, code, verifier string) (string, error) {
|
||
|
|
form := url.Values{
|
||
|
|
"grant_type": {"authorization_code"},
|
||
|
|
"code": {code},
|
||
|
|
"redirect_uri": {m.cfg.RedirectURL},
|
||
|
|
"code_verifier": {verifier},
|
||
|
|
}
|
||
|
|
req, err := http.NewRequestWithContext(ctx, "POST", tokenEndpoint, strings.NewReader(form.Encode()))
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
req.Header.Set("Accept", "application/json")
|
||
|
|
req.SetBasicAuth(m.cfg.ClientID, m.cfg.ClientSecret)
|
||
|
|
resp, err := m.cfg.HTTPClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
if resp.StatusCode/100 != 2 {
|
||
|
|
return "", fmt.Errorf("token endpoint %d: %s", resp.StatusCode, truncate(body, 256))
|
||
|
|
}
|
||
|
|
var tr struct {
|
||
|
|
IDToken string `json:"id_token"`
|
||
|
|
AccessToken string `json:"access_token"`
|
||
|
|
TokenType string `json:"token_type"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(body, &tr); err != nil {
|
||
|
|
return "", fmt.Errorf("token response: %w", err)
|
||
|
|
}
|
||
|
|
if tr.IDToken == "" {
|
||
|
|
return "", errors.New("token response missing id_token")
|
||
|
|
}
|
||
|
|
return tr.IDToken, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ensureDiscovery lazily (re)loads the OIDC discovery doc + JWKS. Callers get
|
||
|
|
// a snapshot of both so they do not need to hold the mutex themselves.
|
||
|
|
func (m *Middleware) ensureDiscovery(ctx context.Context) (discoveryDoc, map[string]*rsa.PublicKey, error) {
|
||
|
|
m.mu.Lock()
|
||
|
|
defer m.mu.Unlock()
|
||
|
|
now := m.cfg.Now()
|
||
|
|
if m.discovery.Issuer != "" && now.Sub(m.lastRefresh) < m.cfg.DiscoveryTTL {
|
||
|
|
return m.discovery, m.jwks, nil
|
||
|
|
}
|
||
|
|
disc, err := m.fetchDiscovery(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return discoveryDoc{}, nil, err
|
||
|
|
}
|
||
|
|
keys, err := m.fetchJWKS(ctx, disc.JWKSURI)
|
||
|
|
if err != nil {
|
||
|
|
return discoveryDoc{}, nil, err
|
||
|
|
}
|
||
|
|
m.discovery = disc
|
||
|
|
m.jwks = keys
|
||
|
|
m.lastRefresh = now
|
||
|
|
return disc, keys, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) fetchDiscovery(ctx context.Context) (discoveryDoc, error) {
|
||
|
|
url := strings.TrimRight(m.cfg.Issuer, "/") + "/.well-known/openid-configuration"
|
||
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||
|
|
if err != nil {
|
||
|
|
return discoveryDoc{}, err
|
||
|
|
}
|
||
|
|
resp, err := m.cfg.HTTPClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return discoveryDoc{}, err
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode/100 != 2 {
|
||
|
|
return discoveryDoc{}, fmt.Errorf("discovery %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
var doc discoveryDoc
|
||
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
|
||
|
|
return discoveryDoc{}, err
|
||
|
|
}
|
||
|
|
if doc.AuthorizationEndpoint == "" || doc.TokenEndpoint == "" || doc.JWKSURI == "" {
|
||
|
|
return discoveryDoc{}, errors.New("discovery missing required endpoints")
|
||
|
|
}
|
||
|
|
// Enforce the OP's self-reported issuer matches what we were configured
|
||
|
|
// against, so a token minted by a *different* Pocket-ID never passes.
|
||
|
|
if doc.Issuer != "" && !strings.EqualFold(doc.Issuer, m.cfg.Issuer) {
|
||
|
|
return discoveryDoc{}, fmt.Errorf("discovery issuer %q != configured %q", doc.Issuer, m.cfg.Issuer)
|
||
|
|
}
|
||
|
|
return doc, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) fetchJWKS(ctx context.Context, jwksURL string) (map[string]*rsa.PublicKey, error) {
|
||
|
|
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
resp, err := m.cfg.HTTPClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode/100 != 2 {
|
||
|
|
return nil, fmt.Errorf("jwks %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
var doc jwksDoc
|
||
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out := make(map[string]*rsa.PublicKey, len(doc.Keys))
|
||
|
|
for _, k := range doc.Keys {
|
||
|
|
if k.Kty != "RSA" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
n, err := base64.RawURLEncoding.DecodeString(k.N)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
e, err := base64.RawURLEncoding.DecodeString(k.E)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
eInt := 0
|
||
|
|
for _, b := range e {
|
||
|
|
eInt = eInt<<8 | int(b)
|
||
|
|
}
|
||
|
|
if eInt == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
out[k.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: eInt}
|
||
|
|
}
|
||
|
|
if len(out) == 0 {
|
||
|
|
return nil, errors.New("jwks: no usable RSA keys")
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// verifyIDToken parses + validates a compact JWS ID token. It enforces alg
|
||
|
|
// RS256, matches kid against keys, verifies the signature, and asserts
|
||
|
|
// iss, aud, exp, nbf, and nonce. The returned map is the decoded payload.
|
||
|
|
func verifyIDToken(idToken string, keys map[string]*rsa.PublicKey, issuer, clientID, expectedNonce string, now time.Time) (map[string]any, error) {
|
||
|
|
parts := strings.Split(idToken, ".")
|
||
|
|
if len(parts) != 3 {
|
||
|
|
return nil, errors.New("jwt: not three segments")
|
||
|
|
}
|
||
|
|
headerRaw, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("jwt header b64: %w", err)
|
||
|
|
}
|
||
|
|
payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("jwt payload b64: %w", err)
|
||
|
|
}
|
||
|
|
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("jwt sig b64: %w", err)
|
||
|
|
}
|
||
|
|
var header struct {
|
||
|
|
Alg string `json:"alg"`
|
||
|
|
Kid string `json:"kid"`
|
||
|
|
Typ string `json:"typ"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(headerRaw, &header); err != nil {
|
||
|
|
return nil, fmt.Errorf("jwt header: %w", err)
|
||
|
|
}
|
||
|
|
if header.Alg != "RS256" {
|
||
|
|
return nil, fmt.Errorf("jwt alg %q not supported", header.Alg)
|
||
|
|
}
|
||
|
|
key, ok := keys[header.Kid]
|
||
|
|
if !ok {
|
||
|
|
return nil, fmt.Errorf("jwt kid %q not found", header.Kid)
|
||
|
|
}
|
||
|
|
signingInput := []byte(parts[0] + "." + parts[1])
|
||
|
|
sum := sha256.Sum256(signingInput)
|
||
|
|
if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], sig); err != nil {
|
||
|
|
return nil, fmt.Errorf("jwt signature: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
var claims map[string]any
|
||
|
|
if err := json.Unmarshal(payloadRaw, &claims); err != nil {
|
||
|
|
return nil, fmt.Errorf("jwt payload: %w", err)
|
||
|
|
}
|
||
|
|
if iss, _ := claims["iss"].(string); !strings.EqualFold(iss, issuer) {
|
||
|
|
return nil, fmt.Errorf("jwt iss %q != %q", iss, issuer)
|
||
|
|
}
|
||
|
|
if !audienceContains(claims["aud"], clientID) {
|
||
|
|
return nil, fmt.Errorf("jwt aud missing %q", clientID)
|
||
|
|
}
|
||
|
|
if expectedNonce != "" {
|
||
|
|
if nonce, _ := claims["nonce"].(string); nonce != expectedNonce {
|
||
|
|
return nil, errors.New("jwt nonce mismatch")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
nowU := now.Unix()
|
||
|
|
// 30s skew in each direction keeps modestly out-of-sync IdPs usable.
|
||
|
|
const skew int64 = 30
|
||
|
|
if exp, ok := numericClaim(claims["exp"]); ok {
|
||
|
|
if nowU-skew >= exp {
|
||
|
|
return nil, errors.New("jwt expired")
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
return nil, errors.New("jwt missing exp")
|
||
|
|
}
|
||
|
|
if nbf, ok := numericClaim(claims["nbf"]); ok {
|
||
|
|
if nowU+skew < nbf {
|
||
|
|
return nil, errors.New("jwt not yet valid")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if iat, ok := numericClaim(claims["iat"]); ok {
|
||
|
|
if nowU+skew < iat {
|
||
|
|
return nil, errors.New("jwt iat in future")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return claims, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- helpers --------------------------------------------------------------
|
||
|
|
|
||
|
|
// numericClaim accepts both the JSON number and string-wrapped variants that
|
||
|
|
// some IdPs emit for `exp`/`iat` and returns the Unix timestamp.
|
||
|
|
func numericClaim(v any) (int64, bool) {
|
||
|
|
switch x := v.(type) {
|
||
|
|
case float64:
|
||
|
|
return int64(x), true
|
||
|
|
case int64:
|
||
|
|
return x, true
|
||
|
|
case int:
|
||
|
|
return int64(x), true
|
||
|
|
case string:
|
||
|
|
var n int64
|
||
|
|
if _, err := fmt.Sscanf(x, "%d", &n); err == nil {
|
||
|
|
return n, true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 0, false
|
||
|
|
}
|
||
|
|
|
||
|
|
// audienceContains handles aud as either a single string or an array.
|
||
|
|
func audienceContains(v any, want string) bool {
|
||
|
|
switch x := v.(type) {
|
||
|
|
case string:
|
||
|
|
return x == want
|
||
|
|
case []any:
|
||
|
|
for _, a := range x {
|
||
|
|
if s, ok := a.(string); ok && s == want {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// extractGroups collects the groups claim. Pocket-ID emits `groups` as a
|
||
|
|
// JSON array of strings per the custom-claim mapping configured in the admin
|
||
|
|
// UI. We also accept the Keycloak-style `roles` fallback so operators who
|
||
|
|
// re-use an existing OIDC client still work.
|
||
|
|
func extractGroups(claims map[string]any) []string {
|
||
|
|
for _, key := range []string{"groups", "roles"} {
|
||
|
|
raw, ok := claims[key]
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
arr, ok := raw.([]any)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
out := make([]string, 0, len(arr))
|
||
|
|
for _, v := range arr {
|
||
|
|
if s, ok := v.(string); ok {
|
||
|
|
out = append(out, s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(out) > 0 {
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func containsString(xs []string, want string) bool {
|
||
|
|
for _, x := range xs {
|
||
|
|
if x == want {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func truncate(b []byte, n int) string {
|
||
|
|
if len(b) <= n {
|
||
|
|
return string(b)
|
||
|
|
}
|
||
|
|
return string(b[:n]) + "…"
|
||
|
|
}
|
||
|
|
|
||
|
|
// sanitizeNext filters the ?next= hint so an attacker cannot smuggle an
|
||
|
|
// absolute off-domain URL through the login redirect. Only in-site paths that
|
||
|
|
// start with a single leading slash and no scheme/host are accepted.
|
||
|
|
func sanitizeNext(raw, fallback string) string {
|
||
|
|
if raw == "" {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
// reject protocol-relative (//evil.com/...) and schemed URLs outright.
|
||
|
|
if strings.HasPrefix(raw, "//") {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
if !strings.HasPrefix(raw, "/") {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
u, err := url.Parse(raw)
|
||
|
|
if err != nil {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
if u.Scheme != "" || u.Host != "" {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
return raw
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- cookie sealing -------------------------------------------------------
|
||
|
|
|
||
|
|
func (m *Middleware) seal(plain []byte) (string, error) {
|
||
|
|
nonce := make([]byte, m.aead.NonceSize())
|
||
|
|
if _, err := rand.Read(nonce); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
ct := m.aead.Seal(nil, nonce, plain, nil)
|
||
|
|
buf := make([]byte, 0, 2+len(nonce)+len(ct))
|
||
|
|
// 2-byte big-endian nonce length prefix keeps us robust if NonceSize ever
|
||
|
|
// changes under a future cipher upgrade.
|
||
|
|
lp := make([]byte, 2)
|
||
|
|
binary.BigEndian.PutUint16(lp, uint16(len(nonce)))
|
||
|
|
buf = append(buf, lp...)
|
||
|
|
buf = append(buf, nonce...)
|
||
|
|
buf = append(buf, ct...)
|
||
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) open(enc string) ([]byte, error) {
|
||
|
|
raw, err := base64.RawURLEncoding.DecodeString(enc)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if len(raw) < 2 {
|
||
|
|
return nil, errors.New("cookie: truncated")
|
||
|
|
}
|
||
|
|
nl := int(binary.BigEndian.Uint16(raw[:2]))
|
||
|
|
if len(raw) < 2+nl {
|
||
|
|
return nil, errors.New("cookie: truncated nonce")
|
||
|
|
}
|
||
|
|
nonce := raw[2 : 2+nl]
|
||
|
|
ct := raw[2+nl:]
|
||
|
|
return m.aead.Open(nil, nonce, ct, nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) makeCookie(name, value string, maxAgeSec int) *http.Cookie {
|
||
|
|
return &http.Cookie{
|
||
|
|
Name: name,
|
||
|
|
Value: value,
|
||
|
|
Path: "/",
|
||
|
|
MaxAge: maxAgeSec,
|
||
|
|
HttpOnly: true,
|
||
|
|
Secure: m.cfg.Secure,
|
||
|
|
SameSite: http.SameSiteLaxMode,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) clearCookie(name string) *http.Cookie {
|
||
|
|
return &http.Cookie{
|
||
|
|
Name: name,
|
||
|
|
Value: "",
|
||
|
|
Path: "/",
|
||
|
|
MaxAge: -1,
|
||
|
|
HttpOnly: true,
|
||
|
|
Secure: m.cfg.Secure,
|
||
|
|
SameSite: http.SameSiteLaxMode,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Middleware) logError(msg string, err error) {
|
||
|
|
if m.cfg.Logger != nil {
|
||
|
|
m.cfg.Logger.Error("auth: "+msg, "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func randomURLString(n int) (string, error) {
|
||
|
|
b := make([]byte, n)
|
||
|
|
if _, err := rand.Read(b); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func randomStateFallback() string {
|
||
|
|
s, err := randomURLString(16)
|
||
|
|
if err != nil {
|
||
|
|
return "x"
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|