mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 19:41:57 +00:00
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.
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
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, " ") }
|
||||
Reference in New Issue
Block a user