Files
ResolveSpec/pkg/security/oauth_oidc.go
T
Hein 640faeeeaf feat(security): full OAuth 2.1 / OpenID Connect server and OIDC relying-party client
Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid,
at_hash, signed userinfo, RP-initiated and back-channel logout), managed
refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens,
DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592
registration, RFC 9207 iss, signing keyring with rotation.

State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct
backends, four dialect DDLs, conformance cases).

Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL.

PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse.

Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
2026-10-01 14:42:12 +02:00

604 lines
20 KiB
Go

package security
import (
"context"
"crypto/sha256"
"crypto/sha512"
"encoding/json"
"errors"
"hash"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
)
// standard claim sets released by scope (OIDC Core §5.4).
var scopeClaims = map[string][]string{
"profile": {"name", "family_name", "given_name", "middle_name", "nickname", "preferred_username", "profile",
"picture", "website", "gender", "birthdate", "zoneinfo", "locale", "updated_at"},
"email": {"email", "email_verified"},
"address": {"address"},
"phone": {"phone_number", "phone_number_verified"},
}
// --------------------------------------------------------------------------
// Discovery — RFC 8414 / OIDC Discovery / RFC 9728
// --------------------------------------------------------------------------
func (s *OAuthServer) grantTypesSupported() []string {
g := []string{"authorization_code", "refresh_token"}
if s.auth != nil {
g = append(g, "client_credentials")
}
if s.cfg.EnableDeviceFlow {
g = append(g, grantDeviceCode)
}
if s.cfg.EnableTokenExchange {
g = append(g, grantTokenExchange)
}
return g
}
func (s *OAuthServer) authMethodsSupported() []string {
return []string{"none", "client_secret_basic", "client_secret_post", "private_key_jwt"}
}
// serverMetadata builds the fields shared by RFC 8414 authorization-server
// metadata and OIDC discovery metadata.
func (s *OAuthServer) serverMetadata() map[string]any {
m := map[string]any{
"issuer": s.cfg.Issuer,
"authorization_endpoint": s.endpoint("/oauth/authorize"),
"token_endpoint": s.endpoint("/oauth/token"),
"registration_endpoint": s.endpoint("/oauth/register"),
"revocation_endpoint": s.endpoint("/oauth/revoke"),
"introspection_endpoint": s.endpoint("/oauth/introspect"),
"userinfo_endpoint": s.endpoint("/oauth/userinfo"),
"jwks_uri": s.endpoint("/oauth/jwks.json"),
"scopes_supported": s.scopesSupported(),
"response_types_supported": []string{"code"},
"response_modes_supported": []string{"query", "form_post"},
"grant_types_supported": s.grantTypesSupported(),
"code_challenge_methods_supported": []string{"S256"},
"token_endpoint_auth_methods_supported": s.authMethodsSupported(),
"token_endpoint_auth_signing_alg_values_supported": []string{"RS256", "PS256", "ES256", "ES384"},
"revocation_endpoint_auth_methods_supported": s.authMethodsSupported(),
"introspection_endpoint_auth_methods_supported": s.authMethodsSupported(),
"authorization_response_iss_parameter_supported": true,
"prompt_values_supported": []string{"none", "login", "consent"},
"request_parameter_supported": false,
"request_uri_parameter_supported": s.cfg.EnablePAR || s.cfg.RequirePAR,
"claims_parameter_supported": true,
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": s.keys.algs(),
"userinfo_signing_alg_values_supported": append([]string{"none"}, s.keys.algs()...),
"claim_types_supported": []string{"normal"},
"claims_supported": s.claimsSupported(),
"service_documentation": "https://github.com/bitechdev/ResolveSpec/blob/main/pkg/security/OAUTH2_SERVER.md",
}
if len(s.cfg.SupportedACR) > 0 {
m["acr_values_supported"] = s.cfg.SupportedACR
}
if s.cfg.EnablePAR || s.cfg.RequirePAR {
m["pushed_authorization_request_endpoint"] = s.endpoint("/oauth/par")
m["require_pushed_authorization_requests"] = s.cfg.RequirePAR
}
if s.cfg.EnableDeviceFlow {
m["device_authorization_endpoint"] = s.endpoint("/oauth/device_authorization")
}
if s.cfg.EnableDPoP {
m["dpop_signing_alg_values_supported"] = []string{"ES256", "ES384", "RS256", "PS256"}
}
if !s.cfg.DisableLogout {
m["end_session_endpoint"] = s.endpoint("/oauth/logout")
m["backchannel_logout_supported"] = true
m["backchannel_logout_session_supported"] = true
}
return m
}
func (s *OAuthServer) scopesSupported() []string {
out := append([]string(nil), s.cfg.DefaultScopes...)
if s.cfg.ManagedRefreshTokens && !oauthSliceContains(out, "offline_access") {
out = append(out, "offline_access")
}
return out
}
func (s *OAuthServer) claimsSupported() []string {
set := map[string]struct{}{"sub": {}, "iss": {}, "aud": {}, "exp": {}, "iat": {}, "auth_time": {}, "nonce": {},
"acr": {}, "amr": {}, "azp": {}, "at_hash": {}, "sid": {}}
for _, sc := range s.scopesSupported() {
for _, c := range scopeClaims[sc] {
set[c] = struct{}{}
}
}
return sortedKeys(set)
}
// metadataHandler serves one of the well-known documents. The path-insertion forms
// (/.well-known/x/<issuer path>) are answered only for this server's issuer path.
func (s *OAuthServer) metadataHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if rest := r.PathValue("path"); rest != "" {
if "/"+strings.Trim(rest, "/") != strings.TrimSuffix(s.issuerURL.Path, "/") {
http.NotFound(w, r)
return
}
}
var doc map[string]any
switch name {
case "oauth-protected-resource":
doc = map[string]any{
"resource": s.cfg.ResourceIdentifier,
"authorization_servers": []string{s.cfg.Issuer},
"scopes_supported": s.cfg.DefaultScopes,
"bearer_methods_supported": []string{"header"},
}
if s.cfg.EnableDPoP {
doc["dpop_signing_alg_values_supported"] = []string{"ES256", "ES384", "RS256", "PS256"}
}
default:
doc = s.serverMetadata()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
json.NewEncoder(w).Encode(doc) //nolint:errcheck,gosec // G104: best-effort write, error intentionally ignored
}
}
func (s *OAuthServer) jwksHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
json.NewEncoder(w).Encode(map[string]any{"keys": s.keys.jwks()}) //nolint:errcheck,gosec // G104: best-effort write, error intentionally ignored
}
// --------------------------------------------------------------------------
// id_token
// --------------------------------------------------------------------------
type idTokenParams struct {
Client *OAuthServerClient
UserID int
Scopes []string
Nonce string
AuthTime int64
ACR string
AMR []string
SID string
AccessToken string
Claims map[string]any // the OIDC "claims" request parameter
}
// halfHash is the left half of the hash of v (at_hash / c_hash), with the hash of the JWS alg.
func halfHash(alg, v string) string {
var h hash.Hash
switch {
case strings.HasSuffix(alg, "384"):
h = sha512.New384()
case strings.HasSuffix(alg, "512"):
h = sha512.New()
default:
h = sha256.New()
}
h.Write([]byte(v))
sum := h.Sum(nil)
return b64u(sum[:len(sum)/2])
}
func (s *OAuthServer) buildIDToken(ctx context.Context, p idTokenParams) (string, error) {
a := s.anyAuth()
if a == nil {
return "", errors.New("no authenticator configured")
}
user, err := a.OAuthGetUser(ctx, p.UserID)
if err != nil {
return "", err
}
key := s.keys.forAlg(p.Client.IDTokenSignedResponseAlg)
now := time.Now()
claims := jwt.MapClaims{
"iss": s.cfg.Issuer, "sub": strconv.Itoa(p.UserID), "aud": p.Client.ClientID, "azp": p.Client.ClientID,
"exp": now.Add(s.cfg.AccessTokenTTL).Unix(), "iat": now.Unix(),
}
if p.Nonce != "" {
claims["nonce"] = p.Nonce
}
if p.AuthTime != 0 {
claims["auth_time"] = p.AuthTime
}
if p.ACR != "" {
claims["acr"] = p.ACR
}
if len(p.AMR) > 0 {
claims["amr"] = p.AMR
}
if p.SID != "" {
claims["sid"] = p.SID
}
if p.AccessToken != "" {
claims["at_hash"] = halfHash(key.alg, p.AccessToken)
}
for k, v := range s.userClaims(ctx, user, p.UserID, p.Scopes, p.Claims, "id_token") {
if _, reserved := claims[k]; !reserved {
claims[k] = v
}
}
return key.sign(claims, "")
}
// userClaims returns the claims of a user released by scopes plus those requested one by one in the
// OIDC "claims" parameter for destination ("id_token" or "userinfo").
func (s *OAuthServer) userClaims(ctx context.Context, user *UserContext, userID int, scopes []string, claimsParam map[string]any, destination string) map[string]any {
base := map[string]any{}
if user != nil {
if user.UserName != "" {
base["preferred_username"] = user.UserName
}
if user.Email != "" {
base["email"] = user.Email
}
}
requested := map[string]struct{}{}
if m, ok := claimsParam[destination].(map[string]any); ok {
for k := range m {
requested[k] = struct{}{}
}
}
all := base
if s.cfg.ClaimsProvider != nil {
extra, err := s.cfg.ClaimsProvider(ctx, OAuthClaimsRequest{
UserID: userID, Sub: strconv.Itoa(userID), Scopes: scopes, Requested: sortedKeys(requested),
Destination: destination, Base: base,
})
if err == nil {
all = map[string]any{}
for k, v := range base {
all[k] = v
}
for k, v := range extra {
all[k] = v
}
}
}
out := map[string]any{}
allowed := map[string]struct{}{}
for _, sc := range scopes {
for _, c := range scopeClaims[sc] {
allowed[c] = struct{}{}
}
}
for k, v := range all {
_, byScope := allowed[k]
_, asked := requested[k]
if byScope || asked {
out[k] = v
}
}
return out
}
// --------------------------------------------------------------------------
// Access token resolution
// --------------------------------------------------------------------------
// tokenInfo is an active access token.
type tokenInfo struct {
UserID int
Sub string
Username string
Email string
Roles []string
UserLevel int
Scopes []string
ClientID string
Exp, Iat int64
JKT string
Aud []string
JTI string
SID string
Claims map[string]any
JWT bool
Legacy bool // a session token that was not issued through a grant (no recorded scope)
}
func (t *tokenInfo) tokenType() string {
if t.JKT != "" {
return "DPoP"
}
return "Bearer"
}
// resolveAccessToken returns the active access token, or nil. Tokens are our JWT access tokens or
// opaque session tokens.
func (s *OAuthServer) resolveAccessToken(ctx context.Context, token string) *tokenInfo {
if token == "" {
return nil
}
a := s.anyAuth()
if a == nil {
return nil
}
gs := s.grants()
if strings.Count(token, ".") == 2 {
claims, err := s.parseOwnJWT(token, "at+jwt")
if err != nil {
return nil
}
jti, _ := claims["jti"].(string)
sub, _ := claims["sub"].(string)
uid, _ := strconv.Atoi(sub)
if jti == "" || uid == 0 || gs == nil {
return nil
}
rec, err := gs.PeekRefresh(ctx, accessKey(jti))
if err != nil {
return nil // revoked or expired
}
user, err := a.OAuthGetUser(ctx, uid)
if err != nil {
return nil
}
ti := &tokenInfo{UserID: uid, Sub: sub, Username: user.UserName, Email: user.Email, Roles: user.Roles,
UserLevel: user.UserLevel, Scopes: rec.Scopes, ClientID: rec.ClientID, JTI: jti, JWT: true, SID: rec.SessionToken}
ti.Exp = int64(numberOf(claims["exp"]))
ti.Iat = int64(numberOf(claims["iat"]))
ti.JKT, _ = rec.Extra["jkt"].(string)
ti.Aud = stringsOf(rec.Extra["aud"])
ti.Claims, _ = rec.Extra["claims"].(map[string]any)
if len(ti.Aud) == 0 {
ti.Aud = audOf(claims["aud"])
}
return ti
}
info, err := a.OAuthIntrospectToken(ctx, token)
if err != nil || !info.Active {
return nil
}
uid, _ := strconv.Atoi(info.Sub)
ti := &tokenInfo{UserID: uid, Sub: info.Sub, Username: info.Username, Email: info.Email, Roles: info.Roles,
UserLevel: info.UserLevel, Exp: info.Exp, Iat: info.Iat, Legacy: true}
if gs != nil {
if rec, err := gs.PeekRefresh(ctx, accessKey(token)); err == nil {
ti.Legacy = false
ti.Scopes, ti.ClientID, ti.SID = rec.Scopes, rec.ClientID, rec.SessionToken
ti.JKT, _ = rec.Extra["jkt"].(string)
ti.Aud = stringsOf(rec.Extra["aud"])
ti.Claims, _ = rec.Extra["claims"].(map[string]any)
} else if !errors.Is(err, lookup.ErrRefreshInvalid) {
return nil
}
}
return ti
}
func audOf(v any) []string {
switch a := v.(type) {
case string:
return []string{a}
case []any:
return stringsOf(a)
}
return nil
}
// parseOwnJWT verifies a JWT signed by one of this server's keys and returns its claims.
func (s *OAuthServer) parseOwnJWT(token, typ string) (jwt.MapClaims, error) {
return s.parseOwnJWTOpts(token, typ, false)
}
// parseOwnJWTOpts is parseOwnJWT; allowExpired accepts tokens past their exp (id_token_hint).
func (s *OAuthServer) parseOwnJWTOpts(token, typ string, allowExpired bool) (jwt.MapClaims, error) {
claims := jwt.MapClaims{}
opts := []jwt.ParserOption{jwt.WithValidMethods(s.keys.algs()), jwt.WithIssuer(s.cfg.Issuer)}
if allowExpired {
opts = append(opts, jwt.WithoutClaimsValidation())
} else {
opts = append(opts, jwt.WithExpirationRequired())
}
tok, err := jwt.NewParser(opts...).
ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if typ != "" && t.Header["typ"] != typ {
return nil, errors.New("wrong token type")
}
kid, _ := t.Header["kid"].(string)
pub, k := s.keys.publicFor(kid)
if k == nil {
return nil, errors.New("unknown key")
}
return pub, nil
})
if err != nil || !tok.Valid {
return nil, errors.New("invalid token")
}
if allowExpired && claims["iss"] != s.cfg.Issuer {
return nil, errors.New("invalid token")
}
return claims, nil
}
// --------------------------------------------------------------------------
// UserInfo — GET/POST /oauth/userinfo
// --------------------------------------------------------------------------
// bearerFromRequest extracts an access token and its scheme (Bearer or DPoP).
func bearerFromRequest(r *http.Request) (token, scheme string) {
h := r.Header.Get("Authorization")
for _, sch := range []string{"Bearer", "DPoP"} {
if len(h) > len(sch)+1 && strings.EqualFold(h[:len(sch)], sch) && h[len(sch)] == ' ' {
return strings.TrimSpace(h[len(sch)+1:]), sch
}
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err == nil {
if t := r.PostFormValue("access_token"); t != "" {
return t, "Bearer"
}
}
}
return "", ""
}
func (s *OAuthServer) userinfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
challenge := func(code, desc string) {
h := `Bearer error="` + code + `"`
if s.cfg.EnableDPoP {
h += `, DPoP algs="ES256 ES384 RS256 PS256"`
}
w.Header().Set("WWW-Authenticate", h)
writeOAuthError(w, code, desc, http.StatusUnauthorized)
}
token, scheme := bearerFromRequest(r)
if token == "" {
challenge("invalid_token", "missing bearer token")
return
}
ti := s.resolveAccessToken(r.Context(), token)
if ti == nil {
challenge("invalid_token", "token is inactive or invalid")
return
}
if e := s.checkDPoPBinding(r, token, scheme, ti); e != nil {
e.write(w)
return
}
scopes := ti.Scopes
if ti.Legacy {
scopes = []string{"openid", "profile", "email"} // a session token without a recorded grant
} else if !oauthSliceContains(scopes, "openid") {
w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_scope", scope="openid"`)
writeOAuthError(w, "insufficient_scope", "the openid scope is required", http.StatusForbidden)
return
}
user := &UserContext{UserName: ti.Username, Email: ti.Email}
out := s.userClaims(r.Context(), user, ti.UserID, scopes, ti.Claims, "userinfo")
out["sub"] = ti.Sub
var client *OAuthServerClient
if ti.ClientID != "" {
client, _ = s.lookupOrFetchClient(r.Context(), ti.ClientID)
}
if client != nil && client.UserinfoSignedResponseAlg != "" && client.UserinfoSignedResponseAlg != "none" {
key := s.keys.forAlg(client.UserinfoSignedResponseAlg)
claims := jwt.MapClaims{"iss": s.cfg.Issuer, "aud": client.ClientID, "iat": time.Now().Unix()}
for k, v := range out {
claims[k] = v
}
signed, err := key.sign(claims, "")
if err != nil {
writeOAuthError(w, "server_error", "could not sign the response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/jwt")
w.Header().Set("Cache-Control", "no-store")
w.Write([]byte(signed)) //nolint:errcheck,gosec // G104: best-effort write
return
}
writeJSON(w, http.StatusOK, out)
}
// checkDPoPBinding enforces sender-constraining: a DPoP-bound token needs a matching proof and the
// DPoP scheme, and a Bearer-scheme request must not carry a bound token.
func (s *OAuthServer) checkDPoPBinding(r *http.Request, token, scheme string, ti *tokenInfo) *oauthError {
if ti.JKT == "" {
if scheme == "DPoP" {
return &oauthError{Code: "invalid_token", Desc: "token is not DPoP bound", Status: http.StatusUnauthorized,
WWWAuth: `Bearer error="invalid_token"`}
}
return nil
}
fail := func(code, desc string) *oauthError {
return &oauthError{Code: code, Desc: desc, Status: http.StatusUnauthorized,
WWWAuth: `DPoP error="` + code + `", algs="ES256 ES384 RS256 PS256"`}
}
if scheme != "DPoP" {
return fail("invalid_token", "DPoP-bound tokens must use the DPoP scheme")
}
proof, e := s.verifyDPoP(r, token)
if e != nil {
return fail("invalid_dpop_proof", e.Desc)
}
if proof == nil || proof.JKT != ti.JKT {
return fail("invalid_dpop_proof", "proof key does not match the token")
}
return nil
}
// --------------------------------------------------------------------------
// Resource server helpers
// --------------------------------------------------------------------------
// AccessTokenClaims describes a verified access token.
type AccessTokenClaims struct {
Subject string
UserID int
ClientID string
Scopes []string
Audience []string
JTI string
SessionID string
ExpiresAt time.Time
// DPoPKey is the thumbprint of the key the token is bound to, or "".
DPoPKey string
// JWT is true for RFC 9068 JWT access tokens.
JWT bool
}
// VerifyAccessTokenOptions tunes VerifyAccessToken.
type VerifyAccessTokenOptions struct {
// Audience, when set, must be one of the token's audiences.
Audience string
// Scopes that must all be granted.
Scopes []string
}
// VerifyAccessToken validates an access token issued by this server (JWT or opaque) against the
// store, so revoked tokens are rejected.
func (s *OAuthServer) VerifyAccessToken(ctx context.Context, token string, opts VerifyAccessTokenOptions) (*AccessTokenClaims, error) {
ti := s.resolveAccessToken(ctx, token)
if ti == nil {
return nil, errors.New("invalid or inactive access token")
}
if opts.Audience != "" && !oauthSliceContains(ti.Aud, opts.Audience) {
return nil, errors.New("access token audience mismatch")
}
if !scopesCovered(ti.Scopes, opts.Scopes) {
return nil, errors.New("insufficient scope")
}
return &AccessTokenClaims{
Subject: ti.Sub, UserID: ti.UserID, ClientID: ti.ClientID, Scopes: ti.Scopes, Audience: ti.Aud, JTI: ti.JTI,
SessionID: ti.SID, ExpiresAt: time.Unix(ti.Exp, 0), DPoPKey: ti.JKT, JWT: ti.JWT,
}, nil
}
// introspectionInfo converts a resolved token to the RFC 7662 response.
func (s *OAuthServer) introspectionInfo(ti *tokenInfo) *OAuthTokenInfo {
info := &OAuthTokenInfo{
Active: true, Sub: ti.Sub, Username: ti.Username, Email: ti.Email, UserLevel: ti.UserLevel, Roles: ti.Roles,
Exp: ti.Exp, Iat: ti.Iat, Scope: strings.Join(ti.Scopes, " "), ClientID: ti.ClientID,
TokenType: ti.tokenType(), Iss: s.cfg.Issuer, Aud: ti.Aud, Jti: ti.JTI,
}
if ti.JKT != "" {
info.Cnf = map[string]any{"jkt": ti.JKT}
}
return info
}
func itoa(i int) string { return strconv.Itoa(i) }
func joinScopes(s []string) string { return strings.Join(s, " ") }