feat(iam2): Phase 2 — in-tree OIDC/OAuth2 server (authorize, token, userinfo, JWKS)
Complete the OIDC/OAuth2 identity core at the canonical /v1/iam/* paths, matching the live hanzo.id surface so existing clients verify tokens and run the flows unchanged. Additive to Phase 0-1; v1 stays authoritative until cutover. - Discovery at /.well-known and /v1/iam/.well-known/openid-configuration; issuer host-relative and consistent with the tokens' iss. - JWKS publishes every signing cert's public key — RSA/RS256 (the interop path every existing verifier reads), EC ES256/384/512, and post-quantum ML-DSA-65 behind the same seam — keyed by kid with x5c, ETag + 60s cache. Fixes the empty JWKS that left RS256 verifiers unable to resolve a key. - authorize validates client_id + EXACT redirect_uri before any redirect (open-redirect defense), normalizes/enforces S256 PKCE, then delegates to the hosted login which mints the PKCE-bound code. - token: authorization_code (single-use, redirect+nonce bound, PKCE-for-public enforced), refresh_token (opaque, rotation + reuse detection + family revocation), client_credentials; client_secret_basic/post; RFC 6749 error taxonomy (invalid_client 401 + WWW-Authenticate, else 400); no-store. - userinfo authenticates the bearer by hash lookup (revocation) AND signature, returns scope-gated claims. id_token minted on openid, nonce echoed. - Tokens persisted as SHA-256 hashes only; ML-DSA-65 is a real circl-backed jwt.SigningMethod, inert unless a cert selects it. TDD: 85 tests/subtests green — discovery shape, JWKS (RSA/ML-DSA/ETag/dedup/TLS exclusion), ES256 + full ML-DSA-65 round-trip, authorize validation, code/refresh/ client_credentials flows, PKCE tamper, reuse detection, error taxonomy, tenant isolation. go vet clean.
This commit is contained in:
@@ -20,7 +20,10 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
)
|
||||
|
||||
require github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
require (
|
||||
github.com/cloudflare/circl v1.6.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
|
||||
@@ -10,6 +10,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The authorization endpoint: GET/POST /v1/iam/oauth/authorize — the front door
|
||||
// of the authorization-code flow. iam2 validates the request BEFORE it trusts
|
||||
// any redirect: an unknown client_id or an unregistered redirect_uri is answered
|
||||
// in place and NEVER redirected to (RFC 6749 §4.1.2.1), closing the open-redirect
|
||||
// and code-injection surface that a bare pass-through would leave open. A
|
||||
// well-formed request is delegated to the hosted login UI (matching v1), which
|
||||
// collects credentials and posts to /v1/iam/login; that endpoint mints the
|
||||
// PKCE-bound code and the browser lands back on the registered redirect_uri.
|
||||
|
||||
// hostedLoginPath is the default hosted-login route the authorize endpoint hands
|
||||
// a validated request to when the application pins no SigninUrl of its own.
|
||||
const hostedLoginPath = "/login/oauth/authorize"
|
||||
|
||||
// authorizeRequest is the parsed authorize query.
|
||||
type authorizeRequest struct {
|
||||
responseType string
|
||||
clientID string
|
||||
redirectURI string
|
||||
scope string
|
||||
state string
|
||||
nonce string
|
||||
codeChallenge string
|
||||
codeChallengeMethod string
|
||||
resource string
|
||||
responseMode string
|
||||
}
|
||||
|
||||
func authorizeHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
q := authorizeParams(c)
|
||||
|
||||
// 1. Resolve the client. Without a known client there is no trusted
|
||||
// redirect target, so the error is shown in place — never redirected.
|
||||
if q.clientID == "" {
|
||||
return authorizeUserError(c, "client_id is required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, q.clientID)
|
||||
if err != nil {
|
||||
return authorizeUserError(c, "internal error")
|
||||
}
|
||||
if app == nil {
|
||||
return authorizeUserError(c, "unknown client_id")
|
||||
}
|
||||
// 2. redirect_uri must EXACTLY match a registered URI before it can ever
|
||||
// be used as a redirect target. A mismatch is answered in place.
|
||||
if q.redirectURI == "" || !app.IsRedirectUriValid(q.redirectURI) {
|
||||
return authorizeUserError(c, "invalid redirect_uri")
|
||||
}
|
||||
|
||||
// The redirect target is now trusted: protocol errors redirect back to it
|
||||
// with error+state (RFC 6749 §4.1.2.1).
|
||||
if q.responseType != "code" {
|
||||
return authorizeErrorRedirect(c, q, "unsupported_response_type", "only response_type=code is supported")
|
||||
}
|
||||
method := normalizeChallengeMethod(q.codeChallenge, q.codeChallengeMethod)
|
||||
if q.codeChallenge != "" && method != "S256" {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "only S256 PKCE is supported")
|
||||
}
|
||||
if app.ClientSecret == "" && q.codeChallenge == "" {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "PKCE is required for public clients")
|
||||
}
|
||||
|
||||
// Delegate to the hosted login with a clean, re-encoded request. The login
|
||||
// page posts credentials to /v1/iam/login, which mints the code.
|
||||
return c.Redirect(302, hostedLoginTarget(app)+"?"+authorizeForwardQuery(q, method))
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeParams reads the authorize parameters from the query (GET) or form
|
||||
// body (POST).
|
||||
func authorizeParams(c *zip.Ctx) authorizeRequest {
|
||||
return authorizeRequest{
|
||||
responseType: param(c, "response_type"),
|
||||
clientID: param(c, "client_id"),
|
||||
redirectURI: param(c, "redirect_uri"),
|
||||
scope: param(c, "scope"),
|
||||
state: param(c, "state"),
|
||||
nonce: param(c, "nonce"),
|
||||
codeChallenge: param(c, "code_challenge"),
|
||||
codeChallengeMethod: param(c, "code_challenge_method"),
|
||||
resource: param(c, "resource"),
|
||||
responseMode: param(c, "response_mode"),
|
||||
}
|
||||
}
|
||||
|
||||
// hostedLoginTarget is the login URL a validated request is delegated to — the
|
||||
// application's own SigninUrl when set, else the default hosted-login route.
|
||||
func hostedLoginTarget(app *schema.Application) string {
|
||||
if app.SigninUrl != "" {
|
||||
return app.SigninUrl
|
||||
}
|
||||
return hostedLoginPath
|
||||
}
|
||||
|
||||
// authorizeForwardQuery re-encodes the validated request as a clean query string
|
||||
// for the hosted login — reconstructed from known parameters so nothing
|
||||
// unexpected is passed through.
|
||||
func authorizeForwardQuery(q authorizeRequest, method string) string {
|
||||
v := url.Values{}
|
||||
v.Set("response_type", "code")
|
||||
v.Set("client_id", q.clientID)
|
||||
v.Set("redirect_uri", q.redirectURI)
|
||||
setIfPresent(v, "scope", q.scope)
|
||||
setIfPresent(v, "state", q.state)
|
||||
setIfPresent(v, "nonce", q.nonce)
|
||||
if q.codeChallenge != "" {
|
||||
v.Set("code_challenge", q.codeChallenge)
|
||||
v.Set("code_challenge_method", method)
|
||||
}
|
||||
setIfPresent(v, "resource", q.resource)
|
||||
setIfPresent(v, "response_mode", q.responseMode)
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
// authorizeErrorRedirect bounces a protocol error back to the (already
|
||||
// validated) redirect_uri with error+state, in the requested response mode.
|
||||
func authorizeErrorRedirect(c *zip.Ctx, q authorizeRequest, code, desc string) error {
|
||||
v := url.Values{}
|
||||
v.Set("error", code)
|
||||
setIfPresent(v, "error_description", desc)
|
||||
setIfPresent(v, "state", q.state)
|
||||
|
||||
sep := "?"
|
||||
switch {
|
||||
case q.responseMode == "fragment":
|
||||
sep = "#"
|
||||
case strings.Contains(q.redirectURI, "?"):
|
||||
sep = "&"
|
||||
}
|
||||
return c.Redirect(302, q.redirectURI+sep+v.Encode())
|
||||
}
|
||||
|
||||
// authorizeUserError answers a request whose client_id/redirect_uri could not be
|
||||
// validated: the resource owner is informed in place and the request is NOT
|
||||
// redirected anywhere (RFC 6749 §4.1.2.1). The message is server-controlled.
|
||||
func authorizeUserError(c *zip.Ctx, msg string) error {
|
||||
c.SetHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
return c.String(400, "authorization error: "+msg)
|
||||
}
|
||||
|
||||
// normalizeChallengeMethod maps an omitted PKCE method to S256 when a challenge
|
||||
// is present (S256 is the only method iam2 supports); an explicit non-S256
|
||||
// method is returned unchanged so the caller rejects the downgrade.
|
||||
func normalizeChallengeMethod(challenge, method string) string {
|
||||
if challenge == "" {
|
||||
return method
|
||||
}
|
||||
if method == "" || strings.EqualFold(method, "null") {
|
||||
return "S256"
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
// setIfPresent sets a query value only when non-empty.
|
||||
func setIfPresent(v url.Values, key, value string) {
|
||||
if value != "" {
|
||||
v.Set(key, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testRedirect = "https://app.example/callback"
|
||||
|
||||
func authorizeURL(q url.Values) string {
|
||||
return PathAuthorize + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// The authorize endpoint validates the client and redirect_uri BEFORE it will
|
||||
// redirect anywhere: an unknown client or an unregistered redirect_uri is
|
||||
// answered in place (never bounced), closing the open-redirect surface.
|
||||
func TestAuthorize_RefusesToRedirectOnBadClientOrRedirect(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
q url.Values
|
||||
}{
|
||||
{"missing client_id", url.Values{"response_type": {"code"}, "redirect_uri": {testRedirect}}},
|
||||
{"unknown client_id", url.Values{"response_type": {"code"}, "client_id": {"ghost"}, "redirect_uri": {testRedirect}}},
|
||||
{"missing redirect_uri", url.Values{"response_type": {"code"}, "client_id": {"pub"}}},
|
||||
{"unregistered redirect_uri", url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {"https://evil.example/steal"}}},
|
||||
{"redirect near-match", url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect + "/.."}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(tc.q)))
|
||||
if resp.StatusCode != 400 {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
t.Fatalf("must NOT redirect on bad client/redirect; got Location %q", loc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Once the client + redirect_uri are validated, a protocol error bounces back to
|
||||
// the (trusted) redirect_uri with error + state.
|
||||
func TestAuthorize_ProtocolErrorRedirectsToClient(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
t.Run("unsupported response_type", func(t *testing.T) {
|
||||
q := url.Values{"response_type": {"token"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "state": {"xyz"}, "code_challenge": {"abc"}}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if !strings.Contains(loc, "error=unsupported_response_type") || !strings.Contains(loc, "state=xyz") {
|
||||
t.Fatalf("Location = %q", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public client without PKCE", func(t *testing.T) {
|
||||
q := url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "state": {"s1"}}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if !strings.Contains(loc, "error=invalid_request") {
|
||||
t.Fatalf("public client without PKCE should error; Location = %q", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain PKCE rejected", func(t *testing.T) {
|
||||
q := url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_challenge": {"abc"}, "code_challenge_method": {"plain"}}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if !strings.Contains(loc, "error=invalid_request") {
|
||||
t.Fatalf("plain PKCE should be rejected; Location = %q", loc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A well-formed request is delegated to the hosted login with the (re-encoded)
|
||||
// request preserved.
|
||||
func TestAuthorize_DelegatesValidRequest(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
challenge := ComputeS256Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {"pub"},
|
||||
"redirect_uri": {testRedirect},
|
||||
"scope": {"openid profile"},
|
||||
"state": {"state-1"},
|
||||
"nonce": {"nonce-1"},
|
||||
"code_challenge": {challenge},
|
||||
}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
if resp.StatusCode != 302 {
|
||||
t.Fatalf("status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
if !strings.HasPrefix(loc, hostedLoginPath+"?") {
|
||||
t.Fatalf("Location = %q, want hosted-login delegate", loc)
|
||||
}
|
||||
forwarded, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fq := forwarded.Query()
|
||||
if fq.Get("client_id") != "pub" || fq.Get("redirect_uri") != testRedirect ||
|
||||
fq.Get("code_challenge") != challenge || fq.Get("code_challenge_method") != "S256" ||
|
||||
fq.Get("state") != "state-1" || fq.Get("nonce") != "nonce-1" {
|
||||
t.Fatalf("delegated query missing/incorrect: %v", fq)
|
||||
}
|
||||
}
|
||||
|
||||
// A confidential client may authorize without PKCE (it authenticates with its
|
||||
// secret at the token endpoint).
|
||||
func TestAuthorize_ConfidentialWithoutPKCEDelegates(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
|
||||
q := url.Values{"response_type": {"code"}, "client_id": {"conf"}, "redirect_uri": {testRedirect}, "scope": {"openid"}}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
|
||||
if resp.StatusCode != 302 || !strings.HasPrefix(resp.Header.Get("Location"), hostedLoginPath+"?") {
|
||||
t.Fatalf("confidential authorize: status=%d loc=%q", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// requireRedirect asserts a 302 whose Location targets wantPrefix and returns it.
|
||||
func requireRedirect(t *testing.T, resp *http.Response, wantPrefix string) string {
|
||||
t.Helper()
|
||||
if resp.StatusCode != 302 {
|
||||
t.Fatalf("status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
if !strings.HasPrefix(loc, wantPrefix) {
|
||||
t.Fatalf("Location = %q, want prefix %q", loc, wantPrefix)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// certkey resolves the PUBLIC half of a signing Cert and encodes it as a JWK.
|
||||
// It is the one place cert → public-key happens, shared by the JWKS endpoint
|
||||
// (which publishes the key so relying parties can verify) and token
|
||||
// verification (which checks a bearer against it). The public key is read from
|
||||
// the Cert's published x509 certificate when present, else derived from the key
|
||||
// pair; private material never crosses this boundary.
|
||||
|
||||
// certPublicKey returns a Cert's public key, its JOSE alg, and (for x509 certs)
|
||||
// the base64 DER chain for the JWK `x5c`. An ML-DSA cert yields a raw ML-DSA
|
||||
// public key and no chain.
|
||||
func certPublicKey(cert *schema.Cert) (pub crypto.PublicKey, alg string, x5c []string, err error) {
|
||||
if cert == nil {
|
||||
return nil, "", nil, errors.New("jwks: nil cert")
|
||||
}
|
||||
if isMLDSACert(cert) {
|
||||
pk, err := mldsa65PublicFromCert(cert)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return pk, algMLDSA65, nil, nil
|
||||
}
|
||||
if cert.Certificate != "" {
|
||||
block, _ := pem.Decode([]byte(cert.Certificate))
|
||||
if block != nil {
|
||||
x509Cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
a, err := classicalAlg(x509Cert.PublicKey, cert.CryptoAlgorithm)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return x509Cert.PublicKey, a, []string{base64.StdEncoding.EncodeToString(x509Cert.Raw)}, nil
|
||||
}
|
||||
}
|
||||
// Dev/test cert that stores only the private key: derive the public half.
|
||||
signer, err := parsePrivateKeyPEM(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
a, err := classicalAlg(signer.Public(), cert.CryptoAlgorithm)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return signer.Public(), a, nil, nil
|
||||
}
|
||||
|
||||
// certToJWK encodes a Cert's public key as a JWK map: {kty, alg, use:"sig", kid,
|
||||
// key params, x5c?}. kid is the Cert name (what token headers carry), matching
|
||||
// the live hanzo.id JWKS.
|
||||
func certToJWK(cert *schema.Cert) (map[string]any, error) {
|
||||
pub, alg, x5c, err := certPublicKey(cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var jwk map[string]any
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
jwk = rsaJWK(k)
|
||||
case *ecdsa.PublicKey:
|
||||
jwk, err = ecJWK(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *mldsa65.PublicKey:
|
||||
jwk = map[string]any{"kty": "MLDSA", "x": base64.RawURLEncoding.EncodeToString(k.Bytes())}
|
||||
default:
|
||||
return nil, errors.New("jwks: unsupported public key type")
|
||||
}
|
||||
jwk["use"] = "sig"
|
||||
jwk["kid"] = cert.Name
|
||||
jwk["alg"] = alg
|
||||
if len(x5c) > 0 {
|
||||
jwk["x5c"] = x5c
|
||||
}
|
||||
return jwk, nil
|
||||
}
|
||||
|
||||
// rsaJWK encodes an RSA public key's modulus and exponent (RFC 7518 §6.3).
|
||||
func rsaJWK(k *rsa.PublicKey) map[string]any {
|
||||
return map[string]any{
|
||||
"kty": "RSA",
|
||||
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
// ecJWK encodes an EC public key's curve and fixed-width coordinates (RFC 7518
|
||||
// §6.2) and returns the curve's JOSE alg.
|
||||
func ecJWK(k *ecdsa.PublicKey) (map[string]any, error) {
|
||||
var crv string
|
||||
var size int
|
||||
switch k.Curve.Params().BitSize {
|
||||
case 256:
|
||||
crv, size = "P-256", 32
|
||||
case 384:
|
||||
crv, size = "P-384", 48
|
||||
case 521:
|
||||
crv, size = "P-521", 66
|
||||
default:
|
||||
return nil, errors.New("jwks: unsupported EC curve")
|
||||
}
|
||||
return map[string]any{
|
||||
"kty": "EC",
|
||||
"crv": crv,
|
||||
"x": base64.RawURLEncoding.EncodeToString(leftPad(k.X.Bytes(), size)),
|
||||
"y": base64.RawURLEncoding.EncodeToString(leftPad(k.Y.Bytes(), size)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// classicalAlg maps a classical public key (and the Cert's declared algorithm,
|
||||
// when it agrees with the key family) to a JOSE alg. The key type is
|
||||
// authoritative; the declared value only refines RSA (RS256 default, RS512 when
|
||||
// pinned).
|
||||
func classicalAlg(pub crypto.PublicKey, declared string) (string, error) {
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
if strings.EqualFold(declared, "RS512") {
|
||||
return "RS512", nil
|
||||
}
|
||||
return "RS256", nil
|
||||
case *ecdsa.PublicKey:
|
||||
switch k.Curve.Params().BitSize {
|
||||
case 256:
|
||||
return "ES256", nil
|
||||
case 384:
|
||||
return "ES384", nil
|
||||
case 521:
|
||||
return "ES512", nil
|
||||
}
|
||||
return "", errors.New("jwks: unsupported EC curve")
|
||||
default:
|
||||
return "", errors.New("jwks: unsupported public key type")
|
||||
}
|
||||
}
|
||||
|
||||
// leftPad left-zero-pads b to size bytes (EC coordinates are fixed-width).
|
||||
func leftPad(b []byte, size int) []byte {
|
||||
if len(b) >= size {
|
||||
return b
|
||||
}
|
||||
out := make([]byte, size)
|
||||
copy(out[size-len(b):], b)
|
||||
return out
|
||||
}
|
||||
@@ -57,8 +57,13 @@ func MintCode(app *schema.Application, userID, scope, challenge, method, resourc
|
||||
if challenge != "" && method != "S256" {
|
||||
return nil, ErrPKCEPlainRejected
|
||||
}
|
||||
// The token row is keyed by the application's OWNER (its registry owner, e.g.
|
||||
// "admin"), so (Owner, Application) is the application's natural key and the
|
||||
// token endpoint resolves the app back unambiguously. Organization records the
|
||||
// tenant the grant belongs to.
|
||||
return &schema.Token{
|
||||
Owner: app.Organization,
|
||||
Owner: app.Owner,
|
||||
Organization: app.Organization,
|
||||
Application: app.Name,
|
||||
User: userID,
|
||||
Code: code,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Discovery is served at both well-known paths, host-relative, advertising only
|
||||
// what iam2 implements — matching the live hanzo.id surface so a client's
|
||||
// discovery step is unchanged across the backend swap.
|
||||
func TestDiscovery_ShapeAtBothPaths(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
|
||||
for _, path := range []string{PathDiscovery, PathDiscoveryV1} {
|
||||
resp, body := do(t, app, formReqNoBody("GET", path))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("%s: status %d", path, resp.StatusCode)
|
||||
}
|
||||
d := decode(t, body)
|
||||
if d["issuer"] != "https://hanzo.id" {
|
||||
t.Errorf("%s: issuer = %v, want https://hanzo.id", path, d["issuer"])
|
||||
}
|
||||
if d["authorization_endpoint"] != "https://hanzo.id"+PathAuthorize {
|
||||
t.Errorf("%s: authorization_endpoint = %v", path, d["authorization_endpoint"])
|
||||
}
|
||||
if d["token_endpoint"] != "https://hanzo.id"+PathToken {
|
||||
t.Errorf("%s: token_endpoint = %v", path, d["token_endpoint"])
|
||||
}
|
||||
if d["userinfo_endpoint"] != "https://hanzo.id"+PathUserInfo {
|
||||
t.Errorf("%s: userinfo_endpoint = %v", path, d["userinfo_endpoint"])
|
||||
}
|
||||
if d["jwks_uri"] != "https://hanzo.id"+PathJWKS {
|
||||
t.Errorf("%s: jwks_uri = %v", path, d["jwks_uri"])
|
||||
}
|
||||
if !containsStr(d["code_challenge_methods_supported"], "S256") {
|
||||
t.Errorf("%s: S256 not advertised", path)
|
||||
}
|
||||
if containsStr(d["code_challenge_methods_supported"], "plain") {
|
||||
t.Errorf("%s: plain must never be advertised", path)
|
||||
}
|
||||
for _, alg := range []string{"RS256", "ES256", "MLDSA65"} {
|
||||
if !containsStr(d["id_token_signing_alg_values_supported"], alg) {
|
||||
t.Errorf("%s: signing alg %s not advertised", path, alg)
|
||||
}
|
||||
}
|
||||
for _, gt := range []string{"authorization_code", "refresh_token", "client_credentials"} {
|
||||
if !containsStr(d["grant_types_supported"], gt) {
|
||||
t.Errorf("%s: grant %s not advertised", path, gt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The issuer follows the request host (X-Forwarded-Host at the edge), so
|
||||
// discovery and the tokens it describes never split origin.
|
||||
func TestDiscovery_IssuerFollowsForwardedHost(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
req := formReqNoBody("GET", PathDiscovery)
|
||||
req.Header.Set("X-Forwarded-Host", "id.example.test")
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if got := decode(t, body)["issuer"]; got != "https://id.example.test" {
|
||||
t.Fatalf("issuer = %v, want https://id.example.test", got)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(v any, want string) bool {
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// HTTP-level test harness: mount the whole OIDC surface on a fresh store and
|
||||
// drive it through the real router (app.Fiber().Test), so every test exercises
|
||||
// the wire contract a client sees — status codes, headers, redirects, bodies.
|
||||
|
||||
// sharedKey is one RSA key reused across tests (keygen is the slow part; the
|
||||
// crypto under test is identical regardless of which key it is).
|
||||
var (
|
||||
sharedKeyOnce sync.Once
|
||||
sharedKeyVal *rsa.PrivateKey
|
||||
)
|
||||
|
||||
func sharedKey(t *testing.T) *rsa.PrivateKey {
|
||||
t.Helper()
|
||||
sharedKeyOnce.Do(func() {
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sharedKeyVal = k
|
||||
})
|
||||
return sharedKeyVal
|
||||
}
|
||||
|
||||
// appOpts configures a seeded OAuth application.
|
||||
type appOpts struct {
|
||||
clientID string
|
||||
secret string // "" → public (PKCE) client
|
||||
redirectURIs []string
|
||||
refreshHours float64
|
||||
}
|
||||
|
||||
// tctx is the background context used by the test seed helpers.
|
||||
func tctx() context.Context { return context.Background() }
|
||||
|
||||
// newServer mounts the full OIDC surface on a fresh SQLite store.
|
||||
func newServer(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
app := zip.New(zip.Config{AppName: "iam2-test", DisableStartupMessage: true})
|
||||
Mount(app, db)
|
||||
return app, db
|
||||
}
|
||||
|
||||
// seedRSACert creates a named RS256 signing cert holding the shared key.
|
||||
func seedRSACert(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner = "admin"
|
||||
c.Name = name
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
|
||||
c.SetId("admin/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedApp creates an application (org "hanzo") with the given options and a
|
||||
// shared RS256 cert.
|
||||
func seedApp(t *testing.T, db orm.DB, o appOpts) *schema.Application {
|
||||
t.Helper()
|
||||
seedRSACert(t, db, "cert-"+o.clientID)
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner = "admin"
|
||||
a.Name = o.clientID
|
||||
a.ClientId = o.clientID
|
||||
a.ClientSecret = o.secret
|
||||
a.Organization = "hanzo"
|
||||
a.Cert = "cert-" + o.clientID
|
||||
a.EnablePassword = true
|
||||
a.ExpireInHours = 1
|
||||
a.RefreshExpireInHours = o.refreshHours
|
||||
a.RedirectUris = o.redirectURIs
|
||||
a.SetId("admin/" + o.clientID)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed app: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// --- HTTP helpers ---
|
||||
|
||||
func formReq(method, path string, form url.Values) *http.Request {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Host = "hanzo.id"
|
||||
return req
|
||||
}
|
||||
|
||||
func formReqNoBody(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Host = "hanzo.id"
|
||||
return req
|
||||
}
|
||||
|
||||
func jsonReq(method, path string, body any) *http.Request {
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Host = "hanzo.id"
|
||||
return req
|
||||
}
|
||||
|
||||
func do(t *testing.T, app *zip.App, req *http.Request) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("test request %s %s: %v", req.Method, req.URL.Path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp, body
|
||||
}
|
||||
|
||||
func decode(t *testing.T, body []byte) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
t.Fatalf("decode json %q: %v", string(body), err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// loginForCode drives POST /v1/iam/login (type=code) and returns the minted
|
||||
// authorization code from the Response envelope.
|
||||
func loginForCode(t *testing.T, app *zip.App, f map[string]string) (string, *http.Response, []byte) {
|
||||
t.Helper()
|
||||
f["type"] = "code"
|
||||
resp, body := do(t, app, jsonReq("POST", PathLogin, f))
|
||||
m := decode(t, body)
|
||||
code, _ := m["data"].(string)
|
||||
return code, resp, body
|
||||
}
|
||||
|
||||
// exchangeCode drives POST /v1/iam/oauth/token for the authorization_code grant.
|
||||
func exchangeCode(t *testing.T, app *zip.App, form url.Values) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
form.Set("grant_type", "authorization_code")
|
||||
resp, body := do(t, app, formReq("POST", PathToken, form))
|
||||
return resp, decode(t, body)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The JSON Web Key Set: the public half of every active signing Cert, so relying
|
||||
// parties verify the tokens iam2 issues. This is the load-bearing interop
|
||||
// surface — the live hanzo.id JWKS publishes one RSA (RS256) key per Cert, keyed
|
||||
// by `kid` = the Cert name, and every existing verifier reads it. Keys are
|
||||
// deduplicated by kid and ordered stably; the response carries a strong ETag and
|
||||
// a 60s cache, matching live.
|
||||
|
||||
// signingAlgs is the set of JOSE algorithms iam2 publishes signing keys for.
|
||||
// A Cert whose CryptoAlgorithm is outside this set (e.g. an ACME/SSL TLS cert)
|
||||
// is not a token-signing key and is excluded from the JWKS.
|
||||
var signingAlgs = map[string]bool{
|
||||
"RS256": true, "RS512": true,
|
||||
"ES256": true, "ES384": true, "ES512": true,
|
||||
"MLDSA65": true,
|
||||
}
|
||||
|
||||
// jwksHandler serves GET /v1/iam/.well-known/jwks.
|
||||
func jwksHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
certs, err := store.ListCerts(c.Context(), db)
|
||||
if err != nil {
|
||||
return c.JSON(500, map[string]string{"error": "server_error"})
|
||||
}
|
||||
keys := make([]any, 0, len(certs))
|
||||
seen := make(map[string]bool, len(certs))
|
||||
for _, cert := range certs {
|
||||
if !isSigningCert(cert) || seen[cert.Name] {
|
||||
continue
|
||||
}
|
||||
jwk, err := certToJWK(cert)
|
||||
if err != nil {
|
||||
continue // a cert we cannot encode never fails the whole set
|
||||
}
|
||||
seen[cert.Name] = true
|
||||
keys = append(keys, jwk)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]any{"keys": keys})
|
||||
if err != nil {
|
||||
return c.JSON(500, map[string]string{"error": "server_error"})
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||
c.SetHeader("Cache-Control", "public, max-age=60")
|
||||
c.SetHeader("ETag", etag)
|
||||
if c.Header("If-None-Match") == etag {
|
||||
return c.NoContent(304)
|
||||
}
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
return c.Bytes(200, body)
|
||||
}
|
||||
}
|
||||
|
||||
// isSigningCert reports whether a Cert is a token-signing key that belongs in the
|
||||
// JWKS: it must carry key material and a recognized signing algorithm, and must
|
||||
// not be a TLS/SSL certificate.
|
||||
func isSigningCert(cert *schema.Cert) bool {
|
||||
if cert == nil || cert.Name == "" {
|
||||
return false
|
||||
}
|
||||
if cert.PrivateKey == "" && cert.Certificate == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(cert.Type, "SSL") {
|
||||
return false
|
||||
}
|
||||
return signingAlgs[strings.ToUpper(strings.ReplaceAll(cert.CryptoAlgorithm, "-", ""))]
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// seedMLDSACert creates an ML-DSA-65 signing cert (raw base64 private key).
|
||||
func seedMLDSACert(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
_, sk, err := mldsa65.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("mldsa keygen: %v", err)
|
||||
}
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner = "admin"
|
||||
c.Name = name
|
||||
c.CryptoAlgorithm = "MLDSA65"
|
||||
c.PrivateKey = base64.StdEncoding.EncodeToString(sk.Bytes())
|
||||
c.SetId("admin/" + name)
|
||||
if err := c.CreateCtx(tctx()); err != nil {
|
||||
t.Fatalf("seed mldsa cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh server with no signing certs still serves a well-formed, empty key set
|
||||
// — the guard against the earlier bug where JWKS was empty yet discovery
|
||||
// advertised signing algorithms, so verifiers could never resolve a key.
|
||||
func TestJWKS_EmptyButWellFormed(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
resp, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
set := decode(t, body)
|
||||
if keys, ok := set["keys"].([]any); !ok || len(keys) != 0 {
|
||||
t.Fatalf("empty JWKS = %v, want an empty keys array", set["keys"])
|
||||
}
|
||||
}
|
||||
|
||||
// The RSA signing key is published with the exact shape RS256 verifiers read —
|
||||
// kty/alg/use/kid/n/e — and never any private material.
|
||||
func TestJWKS_PublishesRSAPublicKey(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedRSACert(t, db, "cert-hanzo")
|
||||
|
||||
resp, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "public, max-age=60" {
|
||||
t.Errorf("Cache-Control = %q", cc)
|
||||
}
|
||||
if resp.Header.Get("ETag") == "" {
|
||||
t.Error("JWKS must carry a strong ETag")
|
||||
}
|
||||
|
||||
k := jwkByKid(t, body, "cert-hanzo")
|
||||
if k["kty"] != "RSA" || k["alg"] != "RS256" || k["use"] != "sig" {
|
||||
t.Errorf("jwk header wrong: %v", k)
|
||||
}
|
||||
// n encodes the real modulus.
|
||||
nb, err := base64.RawURLEncoding.DecodeString(k["n"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("decode n: %v", err)
|
||||
}
|
||||
if new(big.Int).SetBytes(nb).Cmp(sharedKey(t).N) != 0 {
|
||||
t.Error("jwk modulus does not match the signing key")
|
||||
}
|
||||
// Private material must never appear.
|
||||
for _, secret := range []string{"d", "p", "q", "dp", "dq", "qi"} {
|
||||
if _, bad := k[secret]; bad {
|
||||
t.Fatalf("JWKS leaked private RSA parameter %q", secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A conditional GET with the current ETag is answered 304 (parity with live).
|
||||
func TestJWKS_ETag304(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedRSACert(t, db, "cert-hanzo")
|
||||
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
etag := resp.Header.Get("ETag")
|
||||
req := formReqNoBody("GET", PathJWKS)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
resp2, _ := do(t, app, req)
|
||||
if resp2.StatusCode != 304 {
|
||||
t.Fatalf("conditional GET status = %d, want 304", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A post-quantum ML-DSA-65 cert is published as {kty:MLDSA, alg:MLDSA65, x}.
|
||||
func TestJWKS_PublishesMLDSAKey(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedMLDSACert(t, db, "cert-pq")
|
||||
|
||||
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
k := jwkByKid(t, body, "cert-pq")
|
||||
if k["kty"] != "MLDSA" || k["alg"] != "MLDSA65" || k["use"] != "sig" {
|
||||
t.Errorf("mldsa jwk header wrong: %v", k)
|
||||
}
|
||||
if x, _ := k["x"].(string); x == "" {
|
||||
t.Error("mldsa jwk missing raw public key x")
|
||||
}
|
||||
}
|
||||
|
||||
// A TLS/SSL certificate is not a token-signing key and is excluded.
|
||||
func TestJWKS_ExcludesTLSCert(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedRSACert(t, db, "cert-hanzo")
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner = "admin"
|
||||
c.Name = "cert-tls"
|
||||
c.Type = "SSL"
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
|
||||
c.SetId("admin/cert-tls")
|
||||
if err := c.CreateCtx(tctx()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
if hasKid(t, body, "cert-tls") {
|
||||
t.Fatal("TLS cert must not appear in the JWKS")
|
||||
}
|
||||
if !hasKid(t, body, "cert-hanzo") {
|
||||
t.Fatal("signing cert missing from JWKS")
|
||||
}
|
||||
}
|
||||
|
||||
// Keys are deduplicated by kid so a name reused across owners publishes once.
|
||||
func TestJWKS_DedupesByKid(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
for _, owner := range []string{"admin", "hanzo"} {
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner = owner
|
||||
c.Name = "cert-shared"
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
|
||||
c.SetId(owner + "/cert-shared")
|
||||
if err := c.CreateCtx(tctx()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
set := decode(t, body)
|
||||
keys, _ := set["keys"].([]any)
|
||||
count := 0
|
||||
for _, k := range keys {
|
||||
if k.(map[string]any)["kid"] == "cert-shared" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("kid cert-shared published %d times, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func jwkByKid(t *testing.T, body []byte, kid string) map[string]any {
|
||||
t.Helper()
|
||||
set := decode(t, body)
|
||||
keys, _ := set["keys"].([]any)
|
||||
for _, k := range keys {
|
||||
m := k.(map[string]any)
|
||||
if m["kid"] == kid {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("kid %q not found in JWKS %s", kid, string(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasKid(t *testing.T, body []byte, kid string) bool {
|
||||
t.Helper()
|
||||
set := decode(t, body)
|
||||
keys, _ := set["keys"].([]any)
|
||||
for _, k := range keys {
|
||||
if k.(map[string]any)["kid"] == kid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+191
-27
@@ -3,6 +3,8 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
@@ -15,33 +17,79 @@ import (
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// JWT access-token signing. iam2 signs RS256 today (the interoperable default);
|
||||
// the ML-DSA-65 hybrid method rides in behind the same Signer interface via
|
||||
// luxfi/crypto — kept out of this increment so the token core has no heavy
|
||||
// crypto dep. The signing key comes from the Cert entity (PEM private key);
|
||||
// tests use an ephemeral in-memory RSA key through the same path.
|
||||
// JWT token signing. The signing algorithm is a property of the signing Cert's
|
||||
// key, not a global: an RSA cert signs RS256 (the interoperable default that the
|
||||
// live hanzo.id JWKS serves), an EC cert signs ES256/384/512, and a post-quantum
|
||||
// ML-DSA-65 cert signs MLDSA65 (mldsa.go, behind the same jwt.SigningMethod
|
||||
// seam). The classical path is the load-bearing interop path — every existing
|
||||
// verifier reads the RS256 keys published in the JWKS; ML-DSA is additive and
|
||||
// inert until an ML-DSA Cert is configured. Keys come from the Cert entity
|
||||
// (KMS-backed); tests inject an ephemeral in-memory key through the same path.
|
||||
|
||||
// Claims is the iam2 access-token claim set: the standard registered claims plus
|
||||
// scope and owner (the org — a first-class Hanzo claim the SDK/validators read,
|
||||
// scope-independent).
|
||||
// Claims is the iam2 token claim set: the standard registered claims plus the
|
||||
// Hanzo first-class claims the SDK and downstream validators read. owner and
|
||||
// organization are the tenant (both the org slug); scope carries the granted
|
||||
// scopes; nonce is echoed into the id_token; tokenType distinguishes an
|
||||
// access-token from an id-token. A field is emitted only when populated, so one
|
||||
// struct serves both token shapes without leaking empty claims.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
Azp string `json:"azp,omitempty"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
}
|
||||
|
||||
// Signer signs access tokens with one key. Immutable after construction.
|
||||
// Signer signs tokens with one key under one algorithm. Immutable after
|
||||
// construction; the (method, key, kid, alg) tuple is fixed to the Cert it was
|
||||
// built from so a token can never be signed under a key/alg mismatch.
|
||||
type Signer struct {
|
||||
method jwt.SigningMethod
|
||||
key any // *rsa.PrivateKey (RS256) — extend behind this interface
|
||||
key any // *rsa.PrivateKey | *ecdsa.PrivateKey | *mldsa65.PrivateKey
|
||||
kid string // JWKS key id — the Cert name
|
||||
alg string // JOSE alg — "RS256" | "ES256" | … | "MLDSA65"
|
||||
issuer string
|
||||
}
|
||||
|
||||
// NewRSASignerFromCert builds a Signer from a Cert entity whose PrivateKey is a
|
||||
// PEM-encoded RSA key. issuer is the host-relative issuer (https://<host>).
|
||||
// NewSignerFromCert builds a Signer from a Cert, selecting the algorithm from
|
||||
// the cert's key type: RSA → RS256 (or RS512 when the app pins it), EC → ES256/
|
||||
// ES384/ES512 by curve, ML-DSA → MLDSA65. issuer is the canonical OIDC issuer
|
||||
// (https://<host>) that discovery advertises; it is pinned into every token so
|
||||
// id_token `iss` matches the discovery document. app may be nil (the method is
|
||||
// then chosen purely from the key type).
|
||||
func NewSignerFromCert(cert *schema.Cert, app *schema.Application, issuer string) (*Signer, error) {
|
||||
if cert == nil {
|
||||
return nil, errors.New("jwt: nil cert")
|
||||
}
|
||||
// Post-quantum ML-DSA-65 cert: raw key material, own signing method.
|
||||
if isMLDSACert(cert) {
|
||||
key, err := parseMLDSA65PrivateKey(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{method: SigningMethodMLDSA65, key: key, kid: cert.Name, alg: algMLDSA65, issuer: issuer}, nil
|
||||
}
|
||||
if cert.PrivateKey == "" {
|
||||
return nil, errors.New("jwt: cert has no private key")
|
||||
}
|
||||
key, err := parsePrivateKeyPEM(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
method, alg, err := methodForKey(key, pinnedMethod(app))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{method: method, key: key, kid: cert.Name, alg: alg, issuer: issuer}, nil
|
||||
}
|
||||
|
||||
// NewRSASignerFromCert builds an RS256 Signer from a Cert whose PrivateKey is a
|
||||
// PEM RSA key. Retained as the explicit RSA constructor; NewSignerFromCert is
|
||||
// the general dispatch used by the token endpoint.
|
||||
func NewRSASignerFromCert(cert *schema.Cert, issuer string) (*Signer, error) {
|
||||
if cert == nil || cert.PrivateKey == "" {
|
||||
return nil, errors.New("jwt: cert has no private key")
|
||||
@@ -50,13 +98,13 @@ func NewRSASignerFromCert(cert *schema.Cert, issuer string) (*Signer, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: cert.Name, issuer: issuer}, nil
|
||||
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: cert.Name, alg: "RS256", issuer: issuer}, nil
|
||||
}
|
||||
|
||||
// NewRSASigner builds a Signer directly from an RSA key (used by tests and, in
|
||||
// dev, from an ephemeral key when no Cert is configured).
|
||||
// NewRSASigner builds an RS256 Signer directly from an RSA key (tests and, in
|
||||
// dev, an ephemeral key when no Cert is configured).
|
||||
func NewRSASigner(key *rsa.PrivateKey, kid, issuer string) *Signer {
|
||||
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: kid, issuer: issuer}
|
||||
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: kid, alg: "RS256", issuer: issuer}
|
||||
}
|
||||
|
||||
// Sign issues a signed access token for (app, user) with the given scope. now is
|
||||
@@ -74,17 +122,60 @@ func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: s.issuer,
|
||||
Subject: userID,
|
||||
Audience: jwt.ClaimStrings{app.ClientId},
|
||||
Audience: audienceFor(app, ""),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ID: jti,
|
||||
},
|
||||
Scope: scope,
|
||||
Owner: app.Organization,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Scope: scope,
|
||||
Owner: app.Organization,
|
||||
Organization: app.Organization,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Azp: app.ClientId,
|
||||
TokenType: "access-token",
|
||||
}
|
||||
return s.signClaims(claims)
|
||||
}
|
||||
|
||||
// SignID issues an OIDC id_token for (app, user). It differs from the access
|
||||
// token by carrying the echoed nonce and by declaring tokenType "id-token"; the
|
||||
// audience is the client the token was minted for (the RP), and iss matches the
|
||||
// discovery issuer so a standard OIDC client validates it.
|
||||
func (s *Signer) SignID(app *schema.Application, userID, email, name, scope, nonce string, ttl time.Duration, now time.Time) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("jwt: nil signer")
|
||||
}
|
||||
jti, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: s.issuer,
|
||||
Subject: userID,
|
||||
Audience: jwt.ClaimStrings{app.ClientId},
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
ID: jti,
|
||||
},
|
||||
Scope: scope,
|
||||
Owner: app.Organization,
|
||||
Organization: app.Organization,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Nonce: nonce,
|
||||
Azp: app.ClientId,
|
||||
TokenType: "id-token",
|
||||
}
|
||||
return s.signClaims(claims)
|
||||
}
|
||||
|
||||
// signClaims is the single choke point that turns a claim set into a signed
|
||||
// compact JWS under this signer's fixed (method, key, kid).
|
||||
func (s *Signer) signClaims(claims Claims) (string, error) {
|
||||
tok := jwt.NewWithClaims(s.method, claims)
|
||||
if s.kid != "" {
|
||||
tok.Header["kid"] = s.kid
|
||||
@@ -92,7 +183,8 @@ func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string
|
||||
return tok.SignedString(s.key)
|
||||
}
|
||||
|
||||
// PublicKey returns the signer's RSA public key (for JWKS + test verification).
|
||||
// PublicKey returns the signer's RSA public key, or nil for a non-RSA signer
|
||||
// (JWKS + verification read the public key from the Cert, not the Signer).
|
||||
func (s *Signer) PublicKey() *rsa.PublicKey {
|
||||
if k, ok := s.key.(*rsa.PrivateKey); ok {
|
||||
return &k.PublicKey
|
||||
@@ -100,9 +192,81 @@ func (s *Signer) PublicKey() *rsa.PublicKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kid returns the key id.
|
||||
// Kid returns the key id (the Cert name).
|
||||
func (s *Signer) Kid() string { return s.kid }
|
||||
|
||||
// Alg returns the JOSE algorithm this signer uses (matches the JWKS `alg`).
|
||||
func (s *Signer) Alg() string { return s.alg }
|
||||
|
||||
// audienceFor computes the token audience per RFC 8707: an explicit resource
|
||||
// indicator wins; a shared application scopes the audience to the org; otherwise
|
||||
// the audience is the client id (the value validators check).
|
||||
func audienceFor(app *schema.Application, resource string) jwt.ClaimStrings {
|
||||
if resource != "" {
|
||||
return jwt.ClaimStrings{resource}
|
||||
}
|
||||
if app.IsShared && app.Organization != "" {
|
||||
return jwt.ClaimStrings{app.ClientId + "-org-" + app.Organization}
|
||||
}
|
||||
return jwt.ClaimStrings{app.ClientId}
|
||||
}
|
||||
|
||||
// pinnedMethod is the app's requested signing method (TokenSigningMethod), or ""
|
||||
// to let the key type decide.
|
||||
func pinnedMethod(app *schema.Application) string {
|
||||
if app == nil {
|
||||
return ""
|
||||
}
|
||||
return app.TokenSigningMethod
|
||||
}
|
||||
|
||||
// methodForKey maps a parsed private key (and an optional app-pinned method
|
||||
// within the same family) to a jwt.SigningMethod and its JOSE alg name.
|
||||
func methodForKey(key any, pinned string) (jwt.SigningMethod, string, error) {
|
||||
switch k := key.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
if pinned == "RS512" {
|
||||
return jwt.SigningMethodRS512, "RS512", nil
|
||||
}
|
||||
return jwt.SigningMethodRS256, "RS256", nil
|
||||
case *ecdsa.PrivateKey:
|
||||
switch k.Curve.Params().BitSize {
|
||||
case 256:
|
||||
return jwt.SigningMethodES256, "ES256", nil
|
||||
case 384:
|
||||
return jwt.SigningMethodES384, "ES384", nil
|
||||
case 521:
|
||||
return jwt.SigningMethodES512, "ES512", nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("jwt: unsupported EC curve bit size %d", k.Curve.Params().BitSize)
|
||||
default:
|
||||
return nil, "", errors.New("jwt: unsupported private key type")
|
||||
}
|
||||
}
|
||||
|
||||
// parsePrivateKeyPEM decodes a classical (RSA or EC) PEM private key.
|
||||
func parsePrivateKeyPEM(pemText string) (crypto.Signer, error) {
|
||||
block, _ := pem.Decode([]byte(pemText))
|
||||
if block == nil {
|
||||
return nil, errors.New("jwt: private key is not valid PEM")
|
||||
}
|
||||
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
if k, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwt: parse private key: %w", err)
|
||||
}
|
||||
signer, ok := k8.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, errors.New("jwt: PKCS#8 key is not a signing key")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// parseRSAPrivateKeyPEM decodes a PEM RSA private key (PKCS#1 or PKCS#8).
|
||||
func parseRSAPrivateKeyPEM(pemText string) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(pemText))
|
||||
|
||||
+18
-2
@@ -41,6 +41,7 @@ type loginForm struct {
|
||||
RedirectUri string `json:"redirectUri"`
|
||||
State string `json:"state"`
|
||||
Scope string `json:"scope"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
Resource string `json:"resource"`
|
||||
@@ -89,13 +90,28 @@ func loginHandler(db orm.DB) zip.Handler {
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
}
|
||||
if f.CodeChallenge != "" && f.CodeChallengeMethod != "S256" {
|
||||
// Bind the code to an EXACTLY-registered redirect URI (RFC 6749 §3.1.2.3);
|
||||
// the token endpoint re-checks it. A supplied-but-unregistered URI is
|
||||
// refused — never minted against.
|
||||
if f.RedirectUri != "" && !app.IsRedirectUriValid(f.RedirectUri) {
|
||||
return httpx.Err(c, "invalid redirect_uri")
|
||||
}
|
||||
method := normalizeChallengeMethod(f.CodeChallenge, f.CodeChallengeMethod)
|
||||
if f.CodeChallenge != "" && method != "S256" {
|
||||
return httpx.Err(c, "only S256 PKCE is supported")
|
||||
}
|
||||
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, f.CodeChallengeMethod, f.Resource, nowFunc())
|
||||
// A public client (no secret) must use PKCE — no downgrade.
|
||||
if app.ClientSecret == "" && f.CodeChallenge == "" {
|
||||
return httpx.Err(c, "PKCE is required for public clients")
|
||||
}
|
||||
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, method, f.Resource, nowFunc())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// Bind the redirect_uri and nonce onto the code so the token exchange can
|
||||
// re-verify the redirect and echo the nonce into the id_token.
|
||||
code.RedirectUri = f.RedirectUri
|
||||
code.Nonce = f.Nonce
|
||||
if err := store.PersistToken(ctx, db, code); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The end-session endpoint: GET/POST /v1/iam/oauth/logout. iam2 holds no
|
||||
// server-side browser session to destroy here, so logout's security-relevant
|
||||
// job is the redirect: it bounces to post_logout_redirect_uri ONLY when that URI
|
||||
// is registered by the client named in a signature-verified id_token_hint —
|
||||
// never to an unvalidated absolute URL (open-redirect defense).
|
||||
func logoutHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
redirect := param(c, "post_logout_redirect_uri")
|
||||
if redirect == "" {
|
||||
return c.JSON(200, map[string]string{"status": "ok"})
|
||||
}
|
||||
app := appFromIDTokenHint(c.Context(), db, param(c, "id_token_hint"))
|
||||
if app == nil || !app.IsRedirectUriValid(redirect) {
|
||||
// No proof the caller owns the target — refuse to redirect.
|
||||
return c.JSON(200, map[string]string{"status": "ok"})
|
||||
}
|
||||
if state := param(c, "state"); state != "" {
|
||||
sep := "?"
|
||||
if strings.Contains(redirect, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
redirect += sep + "state=" + url.QueryEscape(state)
|
||||
}
|
||||
return c.Redirect(302, redirect)
|
||||
}
|
||||
}
|
||||
|
||||
// appFromIDTokenHint resolves the application an id_token_hint was issued to, but
|
||||
// only when the hint's signature verifies. A forged or unsigned hint yields nil,
|
||||
// so it can never authorize a redirect.
|
||||
func appFromIDTokenHint(ctx context.Context, db orm.DB, hint string) *schema.Application {
|
||||
if hint == "" {
|
||||
return nil
|
||||
}
|
||||
claims, err := verifyToken(ctx, db, hint)
|
||||
if err != nil || len(claims.Audience) == 0 {
|
||||
return nil
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, claims.Audience[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// ML-DSA-65 (FIPS 204, NIST security level 3) as a first-class JWT signing
|
||||
// method. This is the post-quantum half of the hybrid signing story: RS256
|
||||
// (jwt.go) is the classical interop path every existing verifier already reads
|
||||
// from the JWKS, and MLDSA65 is the forward path, active only for a Cert whose
|
||||
// CryptoAlgorithm is ML-DSA. The two share the same Signer / JWKS seam, so a
|
||||
// deployment migrates one Cert at a time without touching the token core.
|
||||
//
|
||||
// The signature scheme is pure ML-DSA-65 over the JWS signing input (no context,
|
||||
// deterministic), which is exactly what circl's Verify checks — so a token this
|
||||
// method signs round-trips through the same package's verify path, and a
|
||||
// PQ-aware relying party reads the raw public key published in the JWKS.
|
||||
|
||||
// algMLDSA65 is the JOSE `alg` value for ML-DSA-65 — the identifier carried in
|
||||
// the JWT header and advertised in discovery + JWKS.
|
||||
const algMLDSA65 = "MLDSA65"
|
||||
|
||||
// signingMethodMLDSA65 implements jwt.SigningMethod for ML-DSA-65.
|
||||
type signingMethodMLDSA65 struct{}
|
||||
|
||||
// SigningMethodMLDSA65 is the shared, stateless ML-DSA-65 signing method.
|
||||
var SigningMethodMLDSA65 jwt.SigningMethod = signingMethodMLDSA65{}
|
||||
|
||||
func init() {
|
||||
jwt.RegisterSigningMethod(algMLDSA65, func() jwt.SigningMethod { return SigningMethodMLDSA65 })
|
||||
}
|
||||
|
||||
// Alg returns the JOSE algorithm identifier.
|
||||
func (signingMethodMLDSA65) Alg() string { return algMLDSA65 }
|
||||
|
||||
// Sign produces a deterministic ML-DSA-65 signature over the JWS signing input.
|
||||
func (signingMethodMLDSA65) Sign(signingString string, key any) ([]byte, error) {
|
||||
sk, ok := key.(*mldsa65.PrivateKey)
|
||||
if !ok {
|
||||
return nil, jwt.ErrInvalidKeyType
|
||||
}
|
||||
sig := make([]byte, mldsa65.SignatureSize)
|
||||
if err := mldsa65.SignTo(sk, []byte(signingString), nil, false, sig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// Verify checks an ML-DSA-65 signature; a mismatch is a signature error, never a
|
||||
// key/type panic.
|
||||
func (signingMethodMLDSA65) Verify(signingString string, sig []byte, key any) error {
|
||||
pk, ok := key.(*mldsa65.PublicKey)
|
||||
if !ok {
|
||||
return jwt.ErrInvalidKeyType
|
||||
}
|
||||
if len(sig) != mldsa65.SignatureSize {
|
||||
return jwt.ErrSignatureInvalid
|
||||
}
|
||||
if !mldsa65.Verify(pk, []byte(signingString), nil, sig) {
|
||||
return jwt.ErrSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMLDSACert reports whether a Cert is an ML-DSA-65 signing cert.
|
||||
func isMLDSACert(cert *schema.Cert) bool {
|
||||
if cert == nil {
|
||||
return false
|
||||
}
|
||||
a := strings.ToUpper(strings.ReplaceAll(cert.CryptoAlgorithm, "-", ""))
|
||||
return a == "MLDSA65"
|
||||
}
|
||||
|
||||
// parseMLDSA65PrivateKey decodes an ML-DSA-65 private key from a Cert's stored
|
||||
// material: a PEM envelope ("MLDSA65 PRIVATE KEY") or bare base64 of the packed
|
||||
// key bytes.
|
||||
func parseMLDSA65PrivateKey(material string) (*mldsa65.PrivateKey, error) {
|
||||
raw, err := decodeKeyMaterial(material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sk := new(mldsa65.PrivateKey)
|
||||
if err := sk.UnmarshalBinary(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sk, nil
|
||||
}
|
||||
|
||||
// parseMLDSA65PublicKey decodes an ML-DSA-65 public key from stored material.
|
||||
func parseMLDSA65PublicKey(material string) (*mldsa65.PublicKey, error) {
|
||||
raw, err := decodeKeyMaterial(material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pk := new(mldsa65.PublicKey)
|
||||
if err := pk.UnmarshalBinary(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
// mldsa65PublicFromCert returns the ML-DSA-65 public key for a cert, from its
|
||||
// published Certificate material when present, else derived from the private key
|
||||
// (dev certs that store only the key). It never returns private material.
|
||||
func mldsa65PublicFromCert(cert *schema.Cert) (*mldsa65.PublicKey, error) {
|
||||
if cert.Certificate != "" {
|
||||
if pk, err := parseMLDSA65PublicKey(cert.Certificate); err == nil {
|
||||
return pk, nil
|
||||
}
|
||||
}
|
||||
sk, err := parseMLDSA65PrivateKey(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, ok := sk.Public().(*mldsa65.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("mldsa: derived public key has the wrong type")
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// decodeKeyMaterial extracts raw key bytes from a PEM envelope or bare base64
|
||||
// (standard or url encoding), the two shapes a Cert row stores raw keys in.
|
||||
func decodeKeyMaterial(material string) ([]byte, error) {
|
||||
material = strings.TrimSpace(material)
|
||||
if material == "" {
|
||||
return nil, errors.New("mldsa: empty key material")
|
||||
}
|
||||
if block, _ := pem.Decode([]byte(material)); block != nil {
|
||||
return block.Bytes, nil
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(material); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(material)
|
||||
if err != nil {
|
||||
return nil, errors.New("mldsa: key material is neither PEM nor base64")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
+59
-41
@@ -1,47 +1,68 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package oidc serves the IAM v2 OpenID Connect surface on zip. Handlers are
|
||||
// RAW zip handlers (func(c *zip.Ctx) error), not typed generics, because the
|
||||
// auth surface needs query params, form bodies, cookies, redirects, and
|
||||
// headers — things a JSON-in/JSON-out typed handler can't reach.
|
||||
// Package oidc serves the IAM v2 OpenID Connect / OAuth2 surface on zip. The
|
||||
// handlers are RAW zip handlers (func(c *zip.Ctx) error), not typed generics,
|
||||
// because the auth surface needs query params, form bodies, redirects, and
|
||||
// headers a JSON-in/JSON-out handler can't reach.
|
||||
//
|
||||
// Phase 2 increment 1 (this file): the read-only discovery + JWKS surface.
|
||||
// The authorize / token / userinfo / logout endpoints follow at the same
|
||||
// canonical paths.
|
||||
// The surface is the canonical hanzo.id contract, unchanged across the v1→v2
|
||||
// backend swap: discovery + JWKS under .well-known, the oauth/{authorize,token,
|
||||
// userinfo,logout} endpoints, and the front-door {get-app-login, auth/methods,
|
||||
// login} the hosted UI calls. Tokens are signed JWTs (RS256 interop, ES/ML-DSA
|
||||
// behind the same JWKS); every value is verified, never trusted.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
)
|
||||
|
||||
// Canonical HIP-0111 OIDC paths — the single source of truth the @hanzo/iam
|
||||
// SDK hard-codes. iam2 is a standalone server, so it serves these directly
|
||||
// (no /v2 transition prefix on the SDK-facing OIDC contract; the v2 prefix is
|
||||
// only on the internal admin CRUD).
|
||||
// Canonical OIDC paths — the single source of truth the @hanzo/iam SDK and every
|
||||
// existing relying party hard-code. iam2 serves them directly; the transition
|
||||
// off v1 is a backend swap behind the same paths, never a parallel version.
|
||||
const (
|
||||
PathAuthorize = "/v1/iam/oauth/authorize"
|
||||
PathToken = "/v1/iam/oauth/token"
|
||||
PathUserInfo = "/v1/iam/oauth/userinfo"
|
||||
PathLogout = "/v1/iam/oauth/logout"
|
||||
PathJWKS = "/v1/iam/.well-known/jwks"
|
||||
PathDiscovery = "/.well-known/openid-configuration"
|
||||
PathAuthorize = "/v1/iam/oauth/authorize"
|
||||
PathToken = "/v1/iam/oauth/token"
|
||||
PathUserInfo = "/v1/iam/oauth/userinfo"
|
||||
PathLogout = "/v1/iam/oauth/logout"
|
||||
PathJWKS = "/v1/iam/.well-known/jwks"
|
||||
PathDiscovery = "/.well-known/openid-configuration"
|
||||
PathDiscoveryV1 = "/v1/iam/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
// Mount registers the OIDC surface on app. Increment 1 wires discovery + JWKS;
|
||||
// subsequent increments add authorize/token/userinfo/logout at the paths above.
|
||||
func Mount(app *zip.App) {
|
||||
// Mount registers the entire OIDC/OAuth2 surface on app, backed by db. This is
|
||||
// the one entry point the route table calls — discovery, JWKS, the protocol
|
||||
// endpoints, and the front door are all wired here so the surface lives in one
|
||||
// place.
|
||||
func Mount(app *zip.App, db orm.DB) {
|
||||
// Discovery is served at both the root well-known path (RFC 8414) and the
|
||||
// /v1/iam-prefixed path, matching the live hanzo.id surface.
|
||||
app.Get(PathDiscovery, Discovery)
|
||||
app.Get(PathJWKS, JWKS)
|
||||
app.Get(PathDiscoveryV1, Discovery)
|
||||
app.Get(PathJWKS, jwksHandler(db))
|
||||
|
||||
// OAuth2 / OIDC protocol endpoints.
|
||||
app.Get(PathAuthorize, authorizeHandler(db))
|
||||
app.Post(PathAuthorize, authorizeHandler(db))
|
||||
app.Get(PathUserInfo, userinfoHandler(db))
|
||||
app.Post(PathUserInfo, userinfoHandler(db))
|
||||
app.Get(PathLogout, logoutHandler(db))
|
||||
app.Post(PathLogout, logoutHandler(db))
|
||||
|
||||
// The token endpoint, the credential login that mints codes, and the
|
||||
// read-only front door the hosted <Login> self-configures from.
|
||||
MountToken(app, db)
|
||||
MountLogin(app, db)
|
||||
MountFrontDoor(app, db)
|
||||
}
|
||||
|
||||
// Discovery serves the OIDC discovery document, host-relative (issuer derived
|
||||
// from the request host) so strict clients never split-origin. It advertises
|
||||
// ONLY the canonical /v1/iam/oauth/* endpoints + jwks; implicit is permanently
|
||||
// absent, PKCE S256 is the only challenge method.
|
||||
// from the request host, the same value the tokens carry as `iss`) so a strict
|
||||
// client never splits origin. It advertises only what iam2 implements: the
|
||||
// authorization-code flow, S256 PKCE, the three supported grants, and the
|
||||
// signing algorithms whose public keys the JWKS actually publishes.
|
||||
func Discovery(c *zip.Ctx) error {
|
||||
iss := "https://" + httpx.EffectiveHost(c)
|
||||
iss := tokenIssuer(c)
|
||||
return c.JSON(200, map[string]any{
|
||||
"issuer": iss,
|
||||
"authorization_endpoint": iss + PathAuthorize,
|
||||
@@ -50,21 +71,18 @@ func Discovery(c *zip.Ctx) error {
|
||||
"end_session_endpoint": iss + PathLogout,
|
||||
"jwks_uri": iss + PathJWKS,
|
||||
"response_types_supported": []string{"code"},
|
||||
"response_modes_supported": []string{"query", "fragment", "form_post"},
|
||||
"grant_types_supported": []string{"authorization_code", "refresh_token", "client_credentials"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "none"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"scopes_supported": []string{"openid", "profile", "email"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "ES256", "MLDSA65"},
|
||||
"claims_supported": []string{"sub", "iss", "aud", "exp", "iat", "email", "name", "owner"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "RS512", "ES256", "ES384", "ES512", "MLDSA65"},
|
||||
"scopes_supported": []string{"openid", "email", "profile", "address", "phone", "offline_access"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"claims_supported": []string{
|
||||
"iss", "sub", "aud", "iat", "exp", "nbf", "jti", "nonce", "azp",
|
||||
"owner", "organization", "scope", "tokenType",
|
||||
"name", "preferred_username", "email", "email_verified",
|
||||
"picture", "address", "phone", "groups", "is_verified",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// JWKS serves the JSON Web Key Set. Increment 1 returns a well-formed empty
|
||||
// set (200, ETag) so tooling and discovery validators succeed; the certs are
|
||||
// wired from the Cert entity when token signing lands (increment 2 — ML-DSA-65
|
||||
// hybrid JWT), keeping this endpoint's shape stable across the increment.
|
||||
func JWKS(c *zip.Ctx) error {
|
||||
c.SetHeader("Cache-Control", "public, max-age=60")
|
||||
return c.JSON(200, map[string]any{"keys": []any{}})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// Refresh-token rotation with reuse detection. A refresh token is an opaque,
|
||||
// single-use bearer (stored only as a SHA-256 hash): every exchange consumes the
|
||||
// presented token and mints a successor in the same rotation family. Presenting
|
||||
// an already-consumed refresh is a replay — the whole family is revoked so a
|
||||
// stolen token cannot outlive its legitimate successor (RFC 9700 §4.14). This is
|
||||
// the load-bearing hardening over v1, whose refresh path is rotate-and-delete
|
||||
// with no family cascade.
|
||||
|
||||
// refreshTokenGrant handles grant_type=refresh_token.
|
||||
func refreshTokenGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
presented := param(c, "refresh_token")
|
||||
if presented == "" {
|
||||
return tokenError(c, 400, "invalid_request", "refresh_token is required")
|
||||
}
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
|
||||
tok, err := store.GetTokenByRefreshHash(ctx, db, hashToken(presented))
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if tok == nil {
|
||||
return tokenError(c, 400, "invalid_grant", "refresh token is invalid or revoked")
|
||||
}
|
||||
app, err := resolveTokenApp(ctx, db, tok)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if app == nil {
|
||||
return tokenError(c, 400, "invalid_grant", "refresh token is invalid or revoked")
|
||||
}
|
||||
|
||||
// Client authentication: the presented client must be the grant's client, and
|
||||
// a confidential client must present its secret.
|
||||
if clientID != "" && subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1 {
|
||||
return tokenError(c, 400, "invalid_grant", "client mismatch")
|
||||
}
|
||||
if app.ClientSecret != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse detection: a consumed token was already rotated. Revoke the whole
|
||||
// family and refuse — a replay means the token leaked.
|
||||
if tok.RefreshConsumed {
|
||||
revokeRefreshFamily(ctx, db, tok.RefreshFamily)
|
||||
return tokenError(c, 400, "invalid_grant", "refresh token replay detected")
|
||||
}
|
||||
if tok.RefreshExpireIn != 0 && now.Unix() > tok.RefreshExpireIn {
|
||||
return tokenError(c, 400, "invalid_grant", "refresh token expired")
|
||||
}
|
||||
|
||||
// Optional scope narrowing — never widening (RFC 6749 §6).
|
||||
scope := tok.Scope
|
||||
if req := param(c, "scope"); req != "" {
|
||||
if !scopeSubset(req, tok.Scope) {
|
||||
return tokenError(c, 400, "invalid_scope", "requested scope exceeds the grant")
|
||||
}
|
||||
scope = req
|
||||
}
|
||||
|
||||
// Rotate: consume the presented token, then mint a successor in the same
|
||||
// family. The successor is a new row so the consumed one remains as a
|
||||
// tripwire for replay until the family is revoked or expires.
|
||||
tok.RefreshConsumed = true
|
||||
if err := store.SaveToken(ctx, db, tok); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
nameSeed, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
nu := &schema.Token{
|
||||
Owner: tok.Owner,
|
||||
Application: tok.Application,
|
||||
Organization: tok.Organization,
|
||||
User: tok.User,
|
||||
Scope: scope,
|
||||
Nonce: tok.Nonce,
|
||||
Resource: tok.Resource,
|
||||
RedirectUri: tok.RedirectUri,
|
||||
}
|
||||
nu.Name = "rt-" + nameSeed[:24]
|
||||
resp, err := issueTokens(ctx, db, c, app, nu, tok.RefreshFamily, now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if err := store.PersistToken(ctx, db, nu); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
return c.JSON(200, resp)
|
||||
}
|
||||
|
||||
// revokeRefreshFamily deletes every token row in a rotation family — the
|
||||
// containment response when a rotated refresh token is replayed.
|
||||
func revokeRefreshFamily(ctx context.Context, db orm.DB, family string) {
|
||||
rows, err := store.ListTokensByRefreshFamily(ctx, db, family)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, r := range rows {
|
||||
_ = store.DeleteToken(ctx, db, r)
|
||||
}
|
||||
}
|
||||
|
||||
// scopeSubset reports whether every scope in sub is present in super.
|
||||
func scopeSubset(sub, super string) bool {
|
||||
for _, s := range strings.Fields(sub) {
|
||||
if !hasScope(super, s) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// grantViaPKCE runs the public authorization-code+PKCE flow and returns the
|
||||
// issued token set.
|
||||
func grantViaPKCE(t *testing.T, app *zip.App, clientID, scope string) map[string]any {
|
||||
t.Helper()
|
||||
verifier := "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
|
||||
params := loginParams(clientID, scope)
|
||||
params["codeChallenge"] = ComputeS256Challenge(verifier)
|
||||
params["codeChallengeMethod"] = "S256"
|
||||
code, _, _ := loginForCode(t, app, params)
|
||||
resp, tok := exchangeCode(t, app, url.Values{
|
||||
"code": {code}, "client_id": {clientID}, "redirect_uri": {testRedirect}, "code_verifier": {verifier},
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("grant failed: %d %v", resp.StatusCode, tok)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func refresh(t *testing.T, app *zip.App, clientID, refreshToken string, extra url.Values) (int, map[string]any) {
|
||||
t.Helper()
|
||||
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refreshToken}, "client_id": {clientID}}
|
||||
for k, vs := range extra {
|
||||
form[k] = vs
|
||||
}
|
||||
resp, tok := postToken(t, app, form)
|
||||
return resp.StatusCode, tok
|
||||
}
|
||||
|
||||
// A refresh rotates: it returns a new access token AND a new refresh token,
|
||||
// distinct from the one presented.
|
||||
func TestRefresh_Rotates(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}, refreshHours: 24})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
tok := grantViaPKCE(t, app, "pub", "openid offline_access")
|
||||
refresh1 := tok["refresh_token"].(string)
|
||||
|
||||
status, out := refresh(t, app, "pub", refresh1, nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("refresh status = %d, body %v", status, out)
|
||||
}
|
||||
refresh2, _ := out["refresh_token"].(string)
|
||||
if refresh2 == "" || refresh2 == refresh1 {
|
||||
t.Fatalf("refresh must rotate the token: got %q (old %q)", refresh2, refresh1)
|
||||
}
|
||||
if out["access_token"] == nil {
|
||||
t.Fatal("refresh must issue a new access token")
|
||||
}
|
||||
}
|
||||
|
||||
// Replaying a rotated (consumed) refresh token is detected and revokes the whole
|
||||
// family — the legitimate successor dies with it.
|
||||
func TestRefresh_ReuseDetectionRevokesFamily(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}, refreshHours: 24})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
tok := grantViaPKCE(t, app, "pub", "openid offline_access")
|
||||
refresh1 := tok["refresh_token"].(string)
|
||||
|
||||
// Legitimate rotation → refresh2.
|
||||
_, out := refresh(t, app, "pub", refresh1, nil)
|
||||
refresh2 := out["refresh_token"].(string)
|
||||
|
||||
// Replay the consumed refresh1 → reuse detected.
|
||||
status, replay := refresh(t, app, "pub", refresh1, nil)
|
||||
if status != 400 || replay["error"] != "invalid_grant" {
|
||||
t.Fatalf("replay of rotated token: status=%d err=%v, want 400 invalid_grant", status, replay["error"])
|
||||
}
|
||||
|
||||
// The family is revoked: the legitimate successor refresh2 no longer works.
|
||||
status, after := refresh(t, app, "pub", refresh2, nil)
|
||||
if status != 400 || after["error"] != "invalid_grant" {
|
||||
t.Fatalf("successor after reuse: status=%d err=%v, want 400 invalid_grant (family revoked)", status, after["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh may narrow scope but never widen it.
|
||||
func TestRefresh_ScopeNarrowingOnly(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}, refreshHours: 24})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
tok := grantViaPKCE(t, app, "pub", "openid profile email")
|
||||
rt := tok["refresh_token"].(string)
|
||||
|
||||
t.Run("narrow ok", func(t *testing.T) {
|
||||
status, out := refresh(t, app, "pub", rt, url.Values{"scope": {"openid"}})
|
||||
if status != 200 || out["scope"] != "openid" {
|
||||
t.Fatalf("narrowing failed: status=%d scope=%v", status, out["scope"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("widen rejected", func(t *testing.T) {
|
||||
tok2 := grantViaPKCE(t, app, "pub", "openid")
|
||||
status, out := refresh(t, app, "pub", tok2["refresh_token"].(string), url.Values{"scope": {"openid profile admin"}})
|
||||
if status != 400 || out["error"] != "invalid_scope" {
|
||||
t.Fatalf("widening should be invalid_scope: status=%d err=%v", status, out["error"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// An unknown refresh token is refused without leaking whether it ever existed.
|
||||
func TestRefresh_UnknownToken(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
status, out := refresh(t, app, "pub", "not-a-real-refresh-token", nil)
|
||||
if status != 400 || out["error"] != "invalid_grant" {
|
||||
t.Fatalf("unknown refresh: status=%d err=%v", status, out["error"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// NewSignerFromCert picks the algorithm from the key type — RSA→RS256,
|
||||
// EC-P256→ES256, ML-DSA→MLDSA65 — so a token can never be signed under a
|
||||
// mismatched alg.
|
||||
func TestNewSignerFromCert_DispatchesByKeyType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cert *schema.Cert
|
||||
want string
|
||||
}{
|
||||
{"rsa", rsaCert(t, "cert-rsa"), "RS256"},
|
||||
{"ec", ecCert(t, "cert-ec"), "ES256"},
|
||||
{"mldsa", mldsaCert(t, "cert-pq"), "MLDSA65"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, err := NewSignerFromCert(tc.cert, testApp(), "https://hanzo.id")
|
||||
if err != nil {
|
||||
t.Fatalf("build signer: %v", err)
|
||||
}
|
||||
if s.Alg() != tc.want {
|
||||
t.Fatalf("alg = %q, want %q", s.Alg(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An ES256 token round-trips: signed under the EC key, verified under its public
|
||||
// half, with the expected claims.
|
||||
func TestSigner_ES256RoundTrip(t *testing.T) {
|
||||
cert := ecCert(t, "cert-ec")
|
||||
s, err := NewSignerFromCert(cert, testApp(), "https://hanzo.id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tok, err := s.Sign(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "openid", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, _, _, err := certPublicKey(cert)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var claims Claims
|
||||
parsed, err := jwt.ParseWithClaims(tok, &claims, func(*jwt.Token) (any, error) { return pub, nil },
|
||||
jwt.WithValidMethods([]string{"ES256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) }))
|
||||
if err != nil || !parsed.Valid {
|
||||
t.Fatalf("verify ES256: %v", err)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" {
|
||||
t.Fatalf("claims wrong: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
// The post-quantum path is real: an ML-DSA-65 token signed by the Signer
|
||||
// verifies through the full package verify path (resolve kid → cert → public
|
||||
// key → circl Verify).
|
||||
func TestSigner_MLDSA65RoundTripThroughVerify(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
cert := mldsaCert(t, "cert-pq")
|
||||
persistCert(t, db, cert)
|
||||
|
||||
s, err := NewSignerFromCert(cert, testApp(), "https://hanzo.id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Alg() != algMLDSA65 {
|
||||
t.Fatalf("alg = %q, want MLDSA65", s.Alg())
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
nowFuncSet(t, now.Add(time.Minute))
|
||||
|
||||
tok, err := s.SignID(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "openid", "nonce-xyz", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claims, err := verifyToken(context.Background(), db, tok)
|
||||
if err != nil {
|
||||
t.Fatalf("verify MLDSA65 token: %v", err)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" || claims.Nonce != "nonce-xyz" || claims.TokenType != "id-token" {
|
||||
t.Fatalf("claims wrong: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
// SignID echoes the nonce and marks the token as an id-token (OIDC Core).
|
||||
func TestSignID_EchoesNonce(t *testing.T) {
|
||||
key := sharedKey(t)
|
||||
s := NewRSASigner(key, "cert-hanzo", "https://hanzo.id")
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tok, err := s.SignID(testApp(), "hanzo/alice", "a@h.ai", "Alice", "openid", "n-123", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var claims Claims
|
||||
if _, err := jwt.ParseWithClaims(tok, &claims, func(*jwt.Token) (any, error) { return &key.PublicKey, nil },
|
||||
jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) })); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Nonce != "n-123" {
|
||||
t.Fatalf("nonce = %q, want n-123", claims.Nonce)
|
||||
}
|
||||
if claims.TokenType != "id-token" {
|
||||
t.Fatalf("tokenType = %q, want id-token", claims.TokenType)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyToken refuses alg:none — a forged unsigned token can never select a
|
||||
// trusting verification path.
|
||||
func TestVerifyToken_RejectsAlgNone(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
persistCert(t, db, rsaCert(t, "cert-hanzo"))
|
||||
|
||||
header := b64url(t, `{"alg":"none","typ":"JWT","kid":"cert-hanzo"}`)
|
||||
payload := b64url(t, `{"sub":"hanzo/attacker","iss":"https://hanzo.id"}`)
|
||||
forged := header + "." + payload + "."
|
||||
if _, err := verifyToken(context.Background(), db, forged); err == nil {
|
||||
t.Fatal("alg:none token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyToken fails closed on a kid that resolves to no signing cert.
|
||||
func TestVerifyToken_RejectsUnknownKid(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
persistCert(t, db, rsaCert(t, "cert-hanzo"))
|
||||
other := rsaCert(t, "cert-ghost") // never persisted
|
||||
s, _ := NewSignerFromCert(other, testApp(), "https://hanzo.id")
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
nowFuncSet(t, now.Add(time.Minute))
|
||||
tok, _ := s.Sign(testApp(), "hanzo/alice", "", "", "openid", time.Hour, now)
|
||||
if _, err := verifyToken(context.Background(), db, tok); err == nil {
|
||||
t.Fatal("token with an unknown kid was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// --- cert builders + helpers ---
|
||||
|
||||
func rsaCert(t *testing.T, name string) *schema.Cert {
|
||||
t.Helper()
|
||||
c := &schema.Cert{CryptoAlgorithm: "RS256", PrivateKey: rsaKeyToPEM(t, sharedKey(t))}
|
||||
c.Owner, c.Name = "admin", name
|
||||
return c
|
||||
}
|
||||
|
||||
func ecCert(t *testing.T, name string) *schema.Cert {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pemText := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}))
|
||||
c := &schema.Cert{CryptoAlgorithm: "ES256", PrivateKey: pemText}
|
||||
c.Owner, c.Name = "admin", name
|
||||
return c
|
||||
}
|
||||
|
||||
func mldsaCert(t *testing.T, name string) *schema.Cert {
|
||||
t.Helper()
|
||||
_, sk, err := mldsa65.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &schema.Cert{CryptoAlgorithm: "MLDSA65", PrivateKey: base64.StdEncoding.EncodeToString(sk.Bytes())}
|
||||
c.Owner, c.Name = "admin", name
|
||||
return c
|
||||
}
|
||||
|
||||
func persistCert(t *testing.T, db orm.DB, cert *schema.Cert) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
model := c.Model
|
||||
*c = *cert
|
||||
c.Model = model
|
||||
c.SetId(cert.Owner + "/" + cert.Name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("persist cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func b64url(t *testing.T, s string) string {
|
||||
t.Helper()
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
// nowFuncSet pins the package clock for the duration of a test.
|
||||
func nowFuncSet(t *testing.T, at time.Time) {
|
||||
t.Helper()
|
||||
prev := nowFunc
|
||||
nowFunc = func() time.Time { return at }
|
||||
t.Cleanup(func() { nowFunc = prev })
|
||||
}
|
||||
+352
-85
@@ -4,34 +4,45 @@ package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The token endpoint: POST /v1/iam/oauth/token. Increment wires the
|
||||
// authorization_code grant end to end — RedeemCode (replay/expiry/client/PKCE)
|
||||
// then a signed RS256 JWT access token — over the store. Other grants
|
||||
// (refresh_token, client_credentials) return unsupported for now; implicit is
|
||||
// permanently disabled.
|
||||
// The token endpoint: POST /v1/iam/oauth/token. It dispatches the three grant
|
||||
// types iam2 issues — authorization_code (PKCE-verified, single-use code →
|
||||
// access JWT + id_token when openid + rotating refresh), refresh_token
|
||||
// (rotation with reuse detection, refresh.go), and client_credentials
|
||||
// (machine-to-machine, no user, no refresh). Every response carries no-store
|
||||
// caching; every error follows the RFC 6749 §5.2 taxonomy (invalid_client → 401
|
||||
// with WWW-Authenticate, everything else → 400). Implicit is permanently absent.
|
||||
|
||||
// nowFunc is indirected so tests can pin time. Production uses time.Now.
|
||||
var nowFunc = time.Now
|
||||
|
||||
// tokenResponse is the RFC 6749 §5.1 success body.
|
||||
// tokenResponse is the RFC 6749 §5.1 / OIDC success body.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
AccessToken string `json:"access_token"`
|
||||
IdToken string `json:"id_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// MountToken registers POST /v1/iam/oauth/token. It needs the store.
|
||||
// MountToken registers POST /v1/iam/oauth/token.
|
||||
func MountToken(app *zip.App, db orm.DB) {
|
||||
app.Post(PathToken, tokenHandler(db))
|
||||
}
|
||||
@@ -47,82 +58,258 @@ func param(c *zip.Ctx, key string) string {
|
||||
|
||||
// tokenError writes the RFC 6749 §5.2 error body with the right status.
|
||||
func tokenError(c *zip.Ctx, status int, code, desc string) error {
|
||||
return c.JSON(status, map[string]string{"error": code, "error_description": desc})
|
||||
body := map[string]string{"error": code}
|
||||
if desc != "" {
|
||||
body["error_description"] = desc
|
||||
}
|
||||
return c.JSON(status, body)
|
||||
}
|
||||
|
||||
// tokenErrorClient answers a client-authentication failure: 401 + the
|
||||
// WWW-Authenticate challenge, per RFC 6749 §5.2.
|
||||
func tokenErrorClient(c *zip.Ctx, desc string) error {
|
||||
c.SetHeader("WWW-Authenticate", `Basic realm="OAuth2"`)
|
||||
return tokenError(c, 401, "invalid_client", desc)
|
||||
}
|
||||
|
||||
// setTokenCacheHeaders forbids caching of any token response (RFC 6749 §5.1).
|
||||
func setTokenCacheHeaders(c *zip.Ctx) {
|
||||
c.SetHeader("Cache-Control", "no-store")
|
||||
c.SetHeader("Pragma", "no-cache")
|
||||
}
|
||||
|
||||
func tokenHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if gt := param(c, "grant_type"); gt != "authorization_code" {
|
||||
if gt == "" {
|
||||
return tokenError(c, 400, "invalid_request", "grant_type is required")
|
||||
}
|
||||
return tokenError(c, 400, "unsupported_grant_type", "only authorization_code is supported")
|
||||
setTokenCacheHeaders(c)
|
||||
switch param(c, "grant_type") {
|
||||
case "authorization_code":
|
||||
return authorizationCodeGrant(c, db)
|
||||
case "refresh_token":
|
||||
return refreshTokenGrant(c, db)
|
||||
case "client_credentials":
|
||||
return clientCredentialsGrant(c, db)
|
||||
case "":
|
||||
return tokenError(c, 400, "invalid_request", "grant_type is required")
|
||||
default:
|
||||
return tokenError(c, 400, "unsupported_grant_type", "unsupported grant_type")
|
||||
}
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
code := param(c, "code")
|
||||
if code == "" {
|
||||
return tokenError(c, 400, "invalid_request", "code is required")
|
||||
}
|
||||
clientID := param(c, "client_id")
|
||||
clientSecret := param(c, "client_secret")
|
||||
verifier := param(c, "code_verifier")
|
||||
|
||||
tok, err := store.GetTokenByCode(ctx, db, code)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", err.Error())
|
||||
}
|
||||
app, err := resolveTokenApp(ctx, db, tok)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", err.Error())
|
||||
}
|
||||
if app == nil {
|
||||
// Unknown code OR its app vanished — one opaque answer, no oracle.
|
||||
return tokenError(c, 400, "invalid_grant", "invalid authorization code")
|
||||
}
|
||||
|
||||
// client_id must match the code's application.
|
||||
if clientID != "" && subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1 {
|
||||
return tokenError(c, 400, "invalid_client", "client_id mismatch")
|
||||
}
|
||||
// Confidential-client secret check (constant-time). A public client (PKCE,
|
||||
// no stored secret) is allowed to send none; a confidential client must
|
||||
// present the right secret.
|
||||
if app.ClientSecret != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenError(c, 401, "invalid_client", "client authentication failed")
|
||||
}
|
||||
}
|
||||
|
||||
// The core guard: replay / expiry / client / PKCE.
|
||||
if err := RedeemCode(tok, app.Name, verifier, now); err != nil {
|
||||
return redeemErrToResponse(c, err)
|
||||
}
|
||||
|
||||
// Mint + sign the access token, mark the code used, persist atomically.
|
||||
ttl := appTTL(app)
|
||||
if err := IssueAccessToken(tok, int(ttl.Seconds()), now); err != nil {
|
||||
return tokenError(c, 500, "server_error", err.Error())
|
||||
}
|
||||
signed, err := signAccessToken(ctx, db, app, tok, ttl, now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", err.Error())
|
||||
}
|
||||
tok.AccessToken = signed
|
||||
if err := store.SaveToken(ctx, db, tok); err != nil {
|
||||
return tokenError(c, 500, "server_error", err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(200, tokenResponse{
|
||||
AccessToken: signed,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(ttl.Seconds()),
|
||||
Scope: tok.Scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// authorizationCodeGrant redeems a single-use, PKCE-bound authorization code for
|
||||
// an access token (+ id_token when openid + rotating refresh).
|
||||
func authorizationCodeGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
code := param(c, "code")
|
||||
if code == "" {
|
||||
return tokenError(c, 400, "invalid_request", "code is required")
|
||||
}
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
verifier := param(c, "code_verifier")
|
||||
redirectURI := param(c, "redirect_uri")
|
||||
|
||||
tok, err := store.GetTokenByCode(ctx, db, code)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
app, err := resolveTokenApp(ctx, db, tok)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if app == nil {
|
||||
// Unknown code OR its app vanished — one opaque answer, no oracle.
|
||||
return tokenError(c, 400, "invalid_grant", "invalid authorization code")
|
||||
}
|
||||
|
||||
// The presented client must be the code's client.
|
||||
if clientID != "" && subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1 {
|
||||
return tokenError(c, 400, "invalid_grant", "client mismatch")
|
||||
}
|
||||
// Confidential client: verify the secret (constant-time). A public client
|
||||
// (PKCE, no stored secret) may present none.
|
||||
if app.ClientSecret != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
}
|
||||
// redirect_uri binding (RFC 6749 §4.1.3): if the code carries one, the token
|
||||
// request must present the same one.
|
||||
if tok.RedirectUri != "" {
|
||||
if redirectURI == "" || subtle.ConstantTimeCompare([]byte(redirectURI), []byte(tok.RedirectUri)) != 1 {
|
||||
return tokenError(c, 400, "invalid_grant", "redirect_uri mismatch")
|
||||
}
|
||||
}
|
||||
// Core guard: replay / expiry / client / PKCE.
|
||||
if err := RedeemCode(tok, app.Name, verifier, now); err != nil {
|
||||
return redeemErrToResponse(c, err)
|
||||
}
|
||||
// A public client MUST have used PKCE — never let a no-secret grant through
|
||||
// without a challenge (downgrade / code injection defense).
|
||||
if app.ClientSecret == "" && tok.CodeChallenge == "" {
|
||||
return tokenError(c, 400, "invalid_grant", "PKCE is required for public clients")
|
||||
}
|
||||
|
||||
// One-shot: burn the code, then mint the grant's tokens onto the same row.
|
||||
tok.CodeIsUsed = true
|
||||
resp, err := issueTokens(ctx, db, c, app, tok, newFamilyID(tok), now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if err := store.SaveToken(ctx, db, tok); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
return c.JSON(200, resp)
|
||||
}
|
||||
|
||||
// clientCredentialsGrant issues a machine-to-machine access token. The subject
|
||||
// is the application itself; there is no end user, no id_token, and no refresh
|
||||
// token (RFC 6749 §4.4 + OIDC — an id_token requires an authenticated user).
|
||||
func clientCredentialsGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if clientID == "" {
|
||||
return tokenErrorClient(c, "client authentication required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
// A public client (no secret) can never use client_credentials.
|
||||
if app == nil || app.ClientSecret == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
if isInternalApp(app) {
|
||||
return tokenErrorClient(c, "client is not permitted on this endpoint")
|
||||
}
|
||||
|
||||
scope := param(c, "scope")
|
||||
ttl := appTTL(app)
|
||||
signer, err := signerFor(ctx, db, app, tokenIssuer(c))
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
sub := app.GetId() // <appOwner>/<appName>, per v1
|
||||
access, err := signer.Sign(app, sub, "", app.Name, scope, ttl, now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
row := &schema.Token{
|
||||
Owner: app.Owner,
|
||||
Application: app.Name,
|
||||
Organization: app.Organization,
|
||||
User: sub,
|
||||
Scope: scope,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(ttl.Seconds()),
|
||||
AccessTokenHash: hashToken(access),
|
||||
}
|
||||
row.Name = "cc-" + hashToken(access)[:32]
|
||||
if err := store.PersistToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
return c.JSON(200, tokenResponse{
|
||||
AccessToken: access,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(ttl.Seconds()),
|
||||
Scope: scope,
|
||||
})
|
||||
}
|
||||
|
||||
// issueTokens mints the grant's tokens onto row: a signed access JWT, an
|
||||
// id_token when the openid scope is present (echoing the stored nonce), and a
|
||||
// rotating opaque refresh token joined to `family`. row already carries the
|
||||
// grant identity (Owner/Application/User/Scope/Nonce). It is the single path
|
||||
// both the code grant and refresh rotation mint through, so the token shape can
|
||||
// never drift between them.
|
||||
func issueTokens(ctx context.Context, db orm.DB, c *zip.Ctx, app *schema.Application, row *schema.Token, family string, now time.Time) (tokenResponse, error) {
|
||||
ttl := appTTL(app)
|
||||
signer, err := signerFor(ctx, db, app, tokenIssuer(c))
|
||||
if err != nil {
|
||||
return tokenResponse{}, err
|
||||
}
|
||||
email, name := userProfile(ctx, db, row.User)
|
||||
|
||||
access, err := signer.Sign(app, row.User, email, name, row.Scope, ttl, now)
|
||||
if err != nil {
|
||||
return tokenResponse{}, err
|
||||
}
|
||||
refresh, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return tokenResponse{}, err
|
||||
}
|
||||
|
||||
// Persist only the SHA-256 hashes, never the reusable plaintext tokens: a
|
||||
// database dump then exposes no usable bearer or refresh credential. Lookups
|
||||
// (userinfo, refresh) go through the hash siblings.
|
||||
row.AccessToken = ""
|
||||
row.AccessTokenHash = hashToken(access)
|
||||
row.RefreshToken = ""
|
||||
row.RefreshTokenHash = hashToken(refresh)
|
||||
row.RefreshFamily = family
|
||||
row.RefreshConsumed = false
|
||||
row.RefreshExpireIn = now.Add(refreshTTL(app)).Unix()
|
||||
row.ExpiresIn = int(ttl.Seconds())
|
||||
row.TokenType = "Bearer"
|
||||
|
||||
resp := tokenResponse{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(ttl.Seconds()),
|
||||
Scope: row.Scope,
|
||||
}
|
||||
if hasScope(row.Scope, "openid") {
|
||||
idt, err := signer.SignID(app, row.User, email, name, row.Scope, row.Nonce, ttl, now)
|
||||
if err != nil {
|
||||
return tokenResponse{}, err
|
||||
}
|
||||
resp.IdToken = idt
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// clientAuth extracts client credentials, preferring client_secret_post (body /
|
||||
// query) and falling back to client_secret_basic (Authorization: Basic).
|
||||
func clientAuth(c *zip.Ctx) (id, secret string) {
|
||||
id, secret = param(c, "client_id"), param(c, "client_secret")
|
||||
if id != "" {
|
||||
return id, secret
|
||||
}
|
||||
if bid, bsecret, ok := parseBasicAuth(c.Header("Authorization")); ok {
|
||||
return bid, bsecret
|
||||
}
|
||||
return id, secret
|
||||
}
|
||||
|
||||
// parseBasicAuth decodes an HTTP Basic client-authentication header. Per RFC
|
||||
// 6749 §2.3.1 the id and secret are form-urlencoded before base64.
|
||||
func parseBasicAuth(header string) (id, secret string, ok bool) {
|
||||
const p = "Basic "
|
||||
if len(header) <= len(p) || !strings.EqualFold(header[:len(p)], p) {
|
||||
return "", "", false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(p):]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
idPart, secretPart, found := strings.Cut(string(raw), ":")
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
if u, err := url.QueryUnescape(idPart); err == nil {
|
||||
idPart = u
|
||||
}
|
||||
if u, err := url.QueryUnescape(secretPart); err == nil {
|
||||
secretPart = u
|
||||
}
|
||||
return idPart, secretPart, true
|
||||
}
|
||||
|
||||
// resolveTokenApp loads the application a token row belongs to. Returns (nil,nil)
|
||||
// for an unknown code so the handler answers invalid_grant without leaking which
|
||||
// of code/app was missing.
|
||||
@@ -141,26 +328,106 @@ func appTTL(app *schema.Application) time.Duration {
|
||||
return time.Hour
|
||||
}
|
||||
|
||||
// signAccessToken loads the app's signing cert and returns a signed RS256 JWT.
|
||||
func signAccessToken(ctx context.Context, db orm.DB, app *schema.Application, tok *schema.Token, ttl time.Duration, now time.Time) (string, error) {
|
||||
// refreshTTL is the refresh-token lifetime (RefreshExpireInHours); when unset it
|
||||
// clamps to the access-token lifetime, matching v1.
|
||||
func refreshTTL(app *schema.Application) time.Duration {
|
||||
if app.RefreshExpireInHours > 0 {
|
||||
return time.Duration(app.RefreshExpireInHours * float64(time.Hour))
|
||||
}
|
||||
return appTTL(app)
|
||||
}
|
||||
|
||||
// signerFor loads the application's signing cert (its own org first, then the
|
||||
// admin org) and builds a Signer with the given canonical issuer.
|
||||
func signerFor(ctx context.Context, db orm.DB, app *schema.Application, issuer string) (*Signer, error) {
|
||||
cert, err := store.GetCert(ctx, db, app.Organization, app.Cert)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
if cert == nil {
|
||||
cert, err = store.GetCert(ctx, db, "admin", app.Cert)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
issuer := "https://" + app.Organization // placeholder issuer when host is absent (tests); the serve path sets a host-relative issuer
|
||||
signer, err := NewRSASignerFromCert(cert, issuer)
|
||||
if cert == nil {
|
||||
return nil, errors.New("token: application has no signing cert")
|
||||
}
|
||||
return NewSignerFromCert(cert, app, issuer)
|
||||
}
|
||||
|
||||
// signAccessToken signs a bare access token for a token row under the given
|
||||
// issuer — the direct sign path the end-to-end test drives.
|
||||
func signAccessToken(ctx context.Context, db orm.DB, app *schema.Application, tok *schema.Token, issuer string, ttl time.Duration, now time.Time) (string, error) {
|
||||
signer, err := signerFor(ctx, db, app, issuer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return signer.Sign(app, tok.User, "", "", tok.Scope, ttl, now)
|
||||
}
|
||||
|
||||
// tokenIssuer is the canonical OIDC issuer for this request (https://<host>),
|
||||
// the value discovery advertises and every token carries as `iss`.
|
||||
func tokenIssuer(c *zip.Ctx) string {
|
||||
if h := httpx.EffectiveHost(c); h != "" {
|
||||
return "https://" + h
|
||||
}
|
||||
return "https://hanzo.id"
|
||||
}
|
||||
|
||||
// userProfile loads a user's email and display name for the token claims.
|
||||
func userProfile(ctx context.Context, db orm.DB, userID string) (email, name string) {
|
||||
owner, uname := splitSub(userID)
|
||||
if owner == "" || uname == "" {
|
||||
return "", ""
|
||||
}
|
||||
u, err := store.GetUserByName(ctx, db, owner, uname)
|
||||
if err != nil || u == nil {
|
||||
return "", ""
|
||||
}
|
||||
name = u.DisplayName
|
||||
if name == "" {
|
||||
name = u.Name
|
||||
}
|
||||
return u.Email, name
|
||||
}
|
||||
|
||||
// splitSub splits a subject "owner/name" into its two parts.
|
||||
func splitSub(sub string) (owner, name string) {
|
||||
owner, name, _ = strings.Cut(sub, "/")
|
||||
return owner, name
|
||||
}
|
||||
|
||||
// hashToken is the SHA-256 hex digest used to index a stored token by a
|
||||
// presented bearer/refresh string without keeping the plaintext on the lookup
|
||||
// path.
|
||||
func hashToken(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// hasScope reports whether a space-delimited scope string contains want.
|
||||
func hasScope(scope, want string) bool {
|
||||
for _, s := range strings.Fields(scope) {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newFamilyID derives a stable refresh-family id for a fresh grant from the
|
||||
// code row's identity — the anchor every rotation of this grant shares.
|
||||
func newFamilyID(tok *schema.Token) string {
|
||||
return tok.Owner + "/" + tok.Name
|
||||
}
|
||||
|
||||
// isInternalApp reports whether an application is an internal service identity
|
||||
// (<org>-iam), which may never obtain a token on the public token endpoint.
|
||||
func isInternalApp(app *schema.Application) bool {
|
||||
return strings.HasSuffix(app.Name, "-iam")
|
||||
}
|
||||
|
||||
// redeemErrToResponse maps a RedeemCode error to the RFC 6749 error body.
|
||||
func redeemErrToResponse(c *zip.Ctx, err error) error {
|
||||
switch err {
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestTokenExchange_EndToEnd(t *testing.T) {
|
||||
if err := IssueAccessToken(got, int(ttl.Seconds()), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed, err := signAccessToken(ctx, db, app, got, ttl, now)
|
||||
signed, err := signAccessToken(ctx, db, app, got, "https://iam.hanzo.ai", ttl, now)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// loginParams builds a type=code login body for org "hanzo" / user alice.
|
||||
func loginParams(clientID, scope string) map[string]string {
|
||||
return map[string]string{
|
||||
"organization": "hanzo",
|
||||
"username": "alice",
|
||||
"password": "pw",
|
||||
"clientId": clientID,
|
||||
"redirectUri": testRedirect,
|
||||
"scope": scope,
|
||||
"nonce": "nonce-1",
|
||||
}
|
||||
}
|
||||
|
||||
// The confidential authorization-code flow, end to end over HTTP: login mints a
|
||||
// code, the token endpoint exchanges it for a verifiable access token, an
|
||||
// id_token that echoes the nonce, and a refresh token — with no-store caching.
|
||||
func TestAuthCodeFlow_ConfidentialHappyPath(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
code, resp, body := loginForCode(t, app, loginParams("conf", "openid profile email"))
|
||||
if code == "" {
|
||||
t.Fatalf("login did not mint a code: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"code": {code},
|
||||
"client_id": {"conf"},
|
||||
"client_secret": {"s3cret"},
|
||||
"redirect_uri": {testRedirect},
|
||||
}
|
||||
tokResp, tok := exchangeCode(t, app, form)
|
||||
if tokResp.StatusCode != 200 {
|
||||
t.Fatalf("token status = %d, body = %v", tokResp.StatusCode, tok)
|
||||
}
|
||||
if cc := tokResp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", cc)
|
||||
}
|
||||
if tok["token_type"] != "Bearer" || tok["access_token"] == nil ||
|
||||
tok["id_token"] == nil || tok["refresh_token"] == nil {
|
||||
t.Fatalf("token response missing fields: %v", tok)
|
||||
}
|
||||
|
||||
// The access token verifies through iam2's own verify path with the right
|
||||
// issuer, audience, subject, and tenant.
|
||||
access := tok["access_token"].(string)
|
||||
claims, err := verifyToken(context.Background(), db, access)
|
||||
if err != nil {
|
||||
t.Fatalf("verify access token: %v", err)
|
||||
}
|
||||
if claims.Issuer != "https://hanzo.id" {
|
||||
t.Errorf("iss = %q, want https://hanzo.id", claims.Issuer)
|
||||
}
|
||||
if len(claims.Audience) != 1 || claims.Audience[0] != "conf" {
|
||||
t.Errorf("aud = %v, want [conf]", claims.Audience)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" {
|
||||
t.Errorf("sub/owner = %q/%q, want hanzo/alice + hanzo", claims.Subject, claims.Owner)
|
||||
}
|
||||
|
||||
// The id_token echoes the request nonce.
|
||||
idClaims, err := verifyToken(context.Background(), db, tok["id_token"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("verify id_token: %v", err)
|
||||
}
|
||||
if idClaims.Nonce != "nonce-1" {
|
||||
t.Errorf("id_token nonce = %q, want nonce-1", idClaims.Nonce)
|
||||
}
|
||||
}
|
||||
|
||||
// The public client flow requires and verifies PKCE; a tampered verifier fails.
|
||||
func TestAuthCodeFlow_PublicPKCE(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
verifier := "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
|
||||
params := loginParams("pub", "openid")
|
||||
params["codeChallenge"] = ComputeS256Challenge(verifier)
|
||||
params["codeChallengeMethod"] = "S256"
|
||||
|
||||
t.Run("valid verifier", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, params)
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_verifier": {verifier}})
|
||||
if resp.StatusCode != 200 || tok["access_token"] == nil {
|
||||
t.Fatalf("valid PKCE exchange failed: %d %v", resp.StatusCode, tok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tampered verifier", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, params)
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_verifier": {"the-WRONG-verifier-000000000000000000000000"}})
|
||||
if resp.StatusCode != 400 || tok["error"] != "invalid_grant" {
|
||||
t.Fatalf("tampered PKCE: status=%d err=%v, want 400 invalid_grant", resp.StatusCode, tok["error"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing verifier", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, params)
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"pub"}, "redirect_uri": {testRedirect}})
|
||||
if resp.StatusCode != 400 || tok["error"] != "invalid_grant" {
|
||||
t.Fatalf("missing verifier: status=%d err=%v", resp.StatusCode, tok["error"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The RFC 6749 §5.2 error taxonomy: invalid_client → 401 + WWW-Authenticate,
|
||||
// every other error → 400, each with the right code.
|
||||
func TestToken_ErrorTaxonomy(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
t.Run("missing grant_type", func(t *testing.T) {
|
||||
resp, tok := postToken(t, app, url.Values{})
|
||||
requireError(t, resp, tok, 400, "invalid_request")
|
||||
})
|
||||
t.Run("unsupported grant_type", func(t *testing.T) {
|
||||
resp, tok := postToken(t, app, url.Values{"grant_type": {"password"}})
|
||||
requireError(t, resp, tok, 400, "unsupported_grant_type")
|
||||
})
|
||||
t.Run("unknown code", func(t *testing.T) {
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {"nope"}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect}})
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
})
|
||||
t.Run("wrong client secret is invalid_client 401", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"WRONG"}, "redirect_uri": {testRedirect}})
|
||||
requireError(t, resp, tok, 401, "invalid_client")
|
||||
if resp.Header.Get("WWW-Authenticate") == "" {
|
||||
t.Error("401 invalid_client must carry WWW-Authenticate")
|
||||
}
|
||||
})
|
||||
t.Run("redirect_uri mismatch", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {"https://app.example/other"}})
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
})
|
||||
t.Run("code is single-use", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
|
||||
form := url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect}}
|
||||
if resp, _ := exchangeCode(t, app, cloneValues(form)); resp.StatusCode != 200 {
|
||||
t.Fatalf("first exchange failed: %d", resp.StatusCode)
|
||||
}
|
||||
resp, tok := exchangeCode(t, app, cloneValues(form))
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
})
|
||||
}
|
||||
|
||||
// A code past its TTL is refused.
|
||||
func TestToken_ExpiredCode(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
base := time.Unix(1_800_000_000, 0)
|
||||
nowFuncSet(t, base)
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
|
||||
|
||||
// Advance past the 5-minute code TTL.
|
||||
nowFuncSet(t, base.Add(codeTTL+time.Minute))
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect}})
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
}
|
||||
|
||||
// client_credentials issues a machine token (no user, no id_token, no refresh);
|
||||
// a public client or a bad secret is refused 401.
|
||||
func TestClientCredentials(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "svc", secret: "svc-secret", redirectURIs: []string{testRedirect}})
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
t.Run("post credentials", func(t *testing.T) {
|
||||
resp, tok := postToken(t, app, url.Values{"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"svc-secret"}, "scope": {"read"}})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, body = %v", resp.StatusCode, tok)
|
||||
}
|
||||
if tok["refresh_token"] != nil || tok["id_token"] != nil {
|
||||
t.Errorf("client_credentials must not issue refresh/id_token: %v", tok)
|
||||
}
|
||||
claims, err := verifyToken(context.Background(), db, tok["access_token"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Subject != "admin/svc" || claims.Owner != "hanzo" {
|
||||
t.Errorf("sub/owner = %q/%q, want admin/svc + hanzo", claims.Subject, claims.Owner)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("basic auth", func(t *testing.T) {
|
||||
req := formReq("POST", PathToken, url.Values{"grant_type": {"client_credentials"}})
|
||||
req.SetBasicAuth("svc", "svc-secret")
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("basic-auth client_credentials: status %d, body %s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong secret", func(t *testing.T) {
|
||||
resp, tok := postToken(t, app, url.Values{"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"nope"}})
|
||||
requireError(t, resp, tok, 401, "invalid_client")
|
||||
})
|
||||
|
||||
t.Run("public client refused", func(t *testing.T) {
|
||||
resp, tok := postToken(t, app, url.Values{"grant_type": {"client_credentials"}, "client_id": {"pub"}})
|
||||
requireError(t, resp, tok, 401, "invalid_client")
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func postToken(t *testing.T, app *zip.App, form url.Values) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
resp, body := do(t, app, formReq("POST", PathToken, form))
|
||||
return resp, decode(t, body)
|
||||
}
|
||||
|
||||
func requireError(t *testing.T, resp *http.Response, tok map[string]any, status int, code string) {
|
||||
t.Helper()
|
||||
if resp.StatusCode != status {
|
||||
t.Fatalf("status = %d, want %d (body %v)", resp.StatusCode, status, tok)
|
||||
}
|
||||
if tok["error"] != code {
|
||||
t.Fatalf("error = %v, want %q", tok["error"], code)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneValues(v url.Values) url.Values {
|
||||
out := url.Values{}
|
||||
for k, vs := range v {
|
||||
out[k] = append([]string(nil), vs...)
|
||||
}
|
||||
// exchangeCode re-sets grant_type; drop it so the clone re-adds cleanly.
|
||||
out.Del("grant_type")
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The userinfo endpoint: GET/POST /v1/iam/oauth/userinfo. A bearer must satisfy
|
||||
// two independent checks — the grant still exists (the token row is looked up by
|
||||
// the SHA-256 hash of the presented token, so a revoked or rotated grant is
|
||||
// already dead) AND the JWT signature verifies under the issuing cert. It then
|
||||
// returns exactly the OIDC claims the token's granted scopes authorize; the
|
||||
// subject is taken from the signed `sub`, so the response can only ever describe
|
||||
// the token's own principal (no cross-tenant read).
|
||||
|
||||
// userinfoHandler serves the userinfo endpoint.
|
||||
func userinfoHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
bearer := httpx.Bearer(c)
|
||||
if bearer == "" {
|
||||
return userinfoUnauthorized(c, "a bearer access token is required")
|
||||
}
|
||||
ctx := c.Context()
|
||||
|
||||
row, err := store.GetTokenByAccessTokenHash(ctx, db, hashToken(bearer))
|
||||
if err != nil {
|
||||
return c.JSON(500, map[string]string{"error": "server_error"})
|
||||
}
|
||||
if row == nil {
|
||||
return userinfoUnauthorized(c, "the access token is invalid or revoked")
|
||||
}
|
||||
claims, err := verifyToken(ctx, db, bearer)
|
||||
if err != nil {
|
||||
return userinfoUnauthorized(c, "the access token is invalid")
|
||||
}
|
||||
|
||||
owner, name := splitSub(claims.Subject)
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return c.JSON(500, map[string]string{"error": "server_error"})
|
||||
}
|
||||
return c.JSON(200, buildUserinfo(user, claims, row, tokenIssuer(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// buildUserinfo assembles the scope-gated claim set. The identifiers (sub, iss,
|
||||
// aud, owner, organization) are always present; every profile/email/address/
|
||||
// phone claim appears only when its scope was granted and the field is set.
|
||||
func buildUserinfo(u *schema.User, claims *Claims, row *schema.Token, iss string) map[string]any {
|
||||
aud := ""
|
||||
if len(claims.Audience) > 0 {
|
||||
aud = claims.Audience[0]
|
||||
}
|
||||
info := map[string]any{
|
||||
"sub": claims.Subject,
|
||||
"iss": iss,
|
||||
"aud": aud,
|
||||
"owner": claims.Owner,
|
||||
}
|
||||
if claims.Organization != "" {
|
||||
info["organization"] = claims.Organization
|
||||
}
|
||||
// A client_credentials token (or a since-deleted user) has no profile.
|
||||
if u == nil {
|
||||
return info
|
||||
}
|
||||
scope := row.Scope
|
||||
if hasScope(scope, "profile") {
|
||||
putIf(info, "preferred_username", u.Name)
|
||||
putIf(info, "name", u.DisplayName)
|
||||
putIf(info, "picture", u.Avatar)
|
||||
putIf(info, "real_name", u.RealName)
|
||||
if len(u.Groups) > 0 {
|
||||
info["groups"] = u.Groups
|
||||
}
|
||||
if u.IsVerified {
|
||||
info["is_verified"] = true
|
||||
}
|
||||
}
|
||||
if hasScope(scope, "email") && u.Email != "" {
|
||||
info["email"] = u.Email
|
||||
info["email_verified"] = u.EmailVerified
|
||||
}
|
||||
if hasScope(scope, "address") && u.Location != "" {
|
||||
info["address"] = u.Location
|
||||
}
|
||||
if hasScope(scope, "phone") && u.Phone != "" {
|
||||
info["phone"] = u.Phone
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// putIf sets key only when v is non-empty (omitempty for a map).
|
||||
func putIf(m map[string]any, key, v string) {
|
||||
if v != "" {
|
||||
m[key] = v
|
||||
}
|
||||
}
|
||||
|
||||
// userinfoUnauthorized answers an invalid/absent bearer with the OIDC 401 shape
|
||||
// and the Bearer challenge, leaking nothing about why beyond the token being
|
||||
// unusable.
|
||||
func userinfoUnauthorized(c *zip.Ctx, desc string) error {
|
||||
c.SetHeader("WWW-Authenticate", `Bearer error="invalid_token", error_description="`+desc+`"`)
|
||||
return c.JSON(401, map[string]string{"error": "invalid_token", "error_description": desc})
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// seedRichUser creates alice with the profile fields userinfo projects.
|
||||
func seedRichUser(t *testing.T, db orm.DB) {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner = "hanzo"
|
||||
u.Name = "alice"
|
||||
u.Email = "alice@hanzo.ai"
|
||||
u.EmailVerified = true
|
||||
u.DisplayName = "Alice Example"
|
||||
u.Phone = "+15551234567"
|
||||
u.Location = "San Francisco"
|
||||
u.PasswordHash = string(hash)
|
||||
u.PasswordType = "bcrypt"
|
||||
u.SetId("hanzo/alice")
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed rich user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// accessTokenFor runs the confidential flow and returns the access token.
|
||||
func accessTokenFor(t *testing.T, app *zip.App, scope string) string {
|
||||
t.Helper()
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", scope))
|
||||
_, tok := exchangeCode(t, app, url.Values{
|
||||
"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect},
|
||||
})
|
||||
access, _ := tok["access_token"].(string)
|
||||
if access == "" {
|
||||
t.Fatal("no access token issued")
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
func userinfo(t *testing.T, app *zip.App, bearer string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := formReqNoBody("GET", PathUserInfo)
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, body := do(t, app, req)
|
||||
return resp.StatusCode, decode(t, body)
|
||||
}
|
||||
|
||||
// userinfo returns exactly the claims the granted scopes authorize.
|
||||
func TestUserinfo_ClaimsByScope(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
access := accessTokenFor(t, app, "openid profile email phone address")
|
||||
status, info := userinfo(t, app, access)
|
||||
if status != 200 {
|
||||
t.Fatalf("userinfo status = %d, body %v", status, info)
|
||||
}
|
||||
want := map[string]any{
|
||||
"sub": "hanzo/alice",
|
||||
"iss": "https://hanzo.id",
|
||||
"aud": "conf",
|
||||
"owner": "hanzo",
|
||||
"organization": "hanzo",
|
||||
"preferred_username": "alice",
|
||||
"name": "Alice Example",
|
||||
"email": "alice@hanzo.ai",
|
||||
"email_verified": true,
|
||||
"phone": "+15551234567",
|
||||
"address": "San Francisco",
|
||||
}
|
||||
for k, v := range want {
|
||||
if info[k] != v {
|
||||
t.Errorf("userinfo[%q] = %v, want %v", k, info[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A narrow scope yields only the identifiers — no profile/email leakage.
|
||||
func TestUserinfo_ScopeGating(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
access := accessTokenFor(t, app, "openid")
|
||||
status, info := userinfo(t, app, access)
|
||||
if status != 200 {
|
||||
t.Fatalf("status %d", status)
|
||||
}
|
||||
for _, leaked := range []string{"email", "preferred_username", "name", "phone", "address"} {
|
||||
if _, ok := info[leaked]; ok {
|
||||
t.Errorf("scope=openid must not expose %q (got %v)", leaked, info[leaked])
|
||||
}
|
||||
}
|
||||
if info["sub"] != "hanzo/alice" {
|
||||
t.Errorf("sub missing: %v", info["sub"])
|
||||
}
|
||||
}
|
||||
|
||||
// No/invalid bearer → 401 invalid_token with the Bearer challenge.
|
||||
func TestUserinfo_Unauthorized(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
t.Run("no bearer", func(t *testing.T) {
|
||||
req := formReqNoBody("GET", PathUserInfo)
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 401 || decode(t, body)["error"] != "invalid_token" {
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
if resp.Header.Get("WWW-Authenticate") == "" {
|
||||
t.Error("401 must carry WWW-Authenticate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("garbage bearer", func(t *testing.T) {
|
||||
status, info := userinfo(t, app, "not.a.jwt")
|
||||
if status != 401 || info["error"] != "invalid_token" {
|
||||
t.Fatalf("status=%d err=%v", status, info["error"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The store keeps only token hashes — never the reusable plaintext bearer or
|
||||
// refresh token — so a database dump exposes no usable credential.
|
||||
func TestTokens_StoredAsHashesOnly(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}, refreshHours: 24})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
tok := grantViaPKCE(t, app, "pub", "openid offline_access")
|
||||
access := tok["access_token"].(string)
|
||||
refresh := tok["refresh_token"].(string)
|
||||
|
||||
row, err := store.GetTokenByAccessTokenHash(context.Background(), db, hashToken(access))
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("locate token row: %v (nil=%v)", err, row == nil)
|
||||
}
|
||||
if row.AccessToken != "" || row.RefreshToken != "" {
|
||||
t.Fatalf("plaintext tokens must not be persisted: access=%q refresh=%q", row.AccessToken, row.RefreshToken)
|
||||
}
|
||||
if row.AccessTokenHash != hashToken(access) || row.RefreshTokenHash != hashToken(refresh) {
|
||||
t.Fatal("token hashes must be persisted for lookup")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the token row revokes the bearer even though the JWT itself is still
|
||||
// within its lifetime — userinfo looks the grant up by hash first.
|
||||
func TestUserinfo_RevokedTokenRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
access := accessTokenFor(t, app, "openid profile")
|
||||
if status, _ := userinfo(t, app, access); status != 200 {
|
||||
t.Fatalf("token should work before revocation: %d", status)
|
||||
}
|
||||
|
||||
// Revoke: delete the stored grant.
|
||||
row, err := store.GetTokenByAccessTokenHash(context.Background(), db, hashToken(access))
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("locate token row: %v (nil=%v)", err, row == nil)
|
||||
}
|
||||
if err := store.DeleteToken(context.Background(), db, row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, info := userinfo(t, app, access); status != 401 || info["error"] != "invalid_token" {
|
||||
t.Fatalf("revoked token: status=%d err=%v, want 401 invalid_token", status, info["error"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// Token verification is a pure reduction of a signed value: read the `kid`,
|
||||
// resolve the matching signing Cert, check the signature under an explicit
|
||||
// algorithm allowlist (never alg:none, never an unexpected method), and validate
|
||||
// the standard time claims. Every protected route reduces a bearer the same way,
|
||||
// so a token is trusted for exactly what it cryptographically is — no more.
|
||||
|
||||
// acceptedAlgs is the closed set of signing algorithms a bearer may carry. It
|
||||
// mirrors the JWKS: the classical interop algorithms plus post-quantum ML-DSA.
|
||||
// alg:none and any HMAC family are absent, so a forged header cannot select a
|
||||
// verification path that trusts attacker-controlled material.
|
||||
var acceptedAlgs = []string{"RS256", "RS512", "ES256", "ES384", "ES512", algMLDSA65}
|
||||
|
||||
// verifyToken parses tokenStr, verifies its signature against the Cert named by
|
||||
// the token's kid, and returns the validated claims. It fails closed on an
|
||||
// unknown kid, a disallowed algorithm, a bad signature, or an expired token.
|
||||
func verifyToken(ctx context.Context, db orm.DB, tokenStr string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
keyFunc := func(t *jwt.Token) (any, error) {
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
if kid == "" {
|
||||
return nil, errors.New("verify: token has no kid")
|
||||
}
|
||||
cert, err := store.FindCertByName(ctx, db, kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cert == nil {
|
||||
return nil, errors.New("verify: unknown signing key")
|
||||
}
|
||||
pub, _, _, err := certPublicKey(cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
if _, err := jwt.ParseWithClaims(tokenStr, claims, keyFunc,
|
||||
jwt.WithValidMethods(acceptedAlgs),
|
||||
jwt.WithTimeFunc(nowFunc),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -39,13 +39,11 @@ import (
|
||||
func Mount(app *zip.App, db orm.DB) {
|
||||
app.Get("/healthz", health)
|
||||
|
||||
// Phase 2 — the OIDC surface at the canonical /v1/iam/* paths (SDK contract):
|
||||
// discovery + JWKS, plus the read-only front-door (get-app-login, auth/methods)
|
||||
// the @hanzo/iam <Login> calls to self-configure.
|
||||
oidc.Mount(app)
|
||||
oidc.MountFrontDoor(app, db)
|
||||
oidc.MountToken(app, db)
|
||||
oidc.MountLogin(app, db)
|
||||
// Phase 2 — the full OIDC/OAuth2 surface at the canonical /v1/iam/* paths
|
||||
// (discovery, JWKS, authorize, token, userinfo, logout) plus the front door
|
||||
// (get-app-login, auth/methods, login) the @hanzo/iam <Login> self-configures
|
||||
// from. One entry point wires the whole identity core.
|
||||
oidc.Mount(app, db)
|
||||
|
||||
users.Mount(app, db)
|
||||
organizations.Mount(app, db)
|
||||
|
||||
@@ -46,4 +46,24 @@ type Token struct {
|
||||
CodeIsUsed bool `json:"codeIsUsed"`
|
||||
CodeExpireIn int64 `json:"codeExpireIn"`
|
||||
Resource string `json:"resource"` // RFC 8707 resource indicator
|
||||
|
||||
// RedirectUri binds the authorization code to the exact redirect URI of the
|
||||
// authorize request (RFC 6749 §4.1.3): the token endpoint refuses a code
|
||||
// redeemed with a different redirect_uri, closing code-injection across a
|
||||
// client's registered URIs.
|
||||
RedirectUri string `json:"redirectUri,omitempty"`
|
||||
|
||||
// Nonce is the OIDC authorize nonce, stored on the code and echoed into the
|
||||
// id_token minted at the exchange (OIDC Core §3.1.3.6) so a relying party
|
||||
// binds the id_token to its own request and detects replay.
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
|
||||
// Refresh-token rotation state (v2). Each refresh belongs to a family (the
|
||||
// grant); rotation mints a new row in the same family and marks the prior
|
||||
// one consumed. Presenting a consumed refresh is reuse — the whole family is
|
||||
// revoked (RFC 9700 §4.14.2). RefreshExpireIn is the refresh token's own
|
||||
// absolute expiry (unix), independent of the access token's shorter life.
|
||||
RefreshFamily string `json:"refreshFamily,omitempty" orm:"index"`
|
||||
RefreshConsumed bool `json:"refreshConsumed,omitempty"`
|
||||
RefreshExpireIn int64 `json:"refreshExpireIn,omitempty"`
|
||||
}
|
||||
|
||||
@@ -82,6 +82,19 @@ func GetCert(_ context.Context, db orm.DB, owner, name string) (*schema.Cert, er
|
||||
return c, err
|
||||
}
|
||||
|
||||
// FindCertByName resolves a signing certificate by name alone (the JWKS `kid`),
|
||||
// across owners. Returns (nil, nil) when no cert carries the name.
|
||||
func FindCertByName(_ context.Context, db orm.DB, name string) (*schema.Cert, error) {
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
c, err := orm.TypedQuery[schema.Cert](db).Filter("Name=", name).First()
|
||||
if err == orm.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// PersistToken wires a domain Token onto the store and creates it. Used to
|
||||
// persist an authorization code minted by oidc.MintCode. The id is (owner, name);
|
||||
// callers set Name to a unique value (e.g. the code) before persisting.
|
||||
@@ -113,6 +126,62 @@ func SaveToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
|
||||
return existing.UpdateCtx(ctx)
|
||||
}
|
||||
|
||||
// ListCerts returns every certificate ordered by name. The JWKS endpoint calls
|
||||
// this and filters to the token-signing certs it publishes.
|
||||
func ListCerts(ctx context.Context, db orm.DB) ([]*schema.Cert, error) {
|
||||
return orm.TypedQuery[schema.Cert](db).Order("Name").GetAll(ctx)
|
||||
}
|
||||
|
||||
// GetTokenByAccessTokenHash resolves a live token row by the SHA-256 hash of a
|
||||
// presented access token — the userinfo bearer lookup. Because the row is the
|
||||
// authorization server's memory of the grant, a deleted/rotated row means the
|
||||
// bearer is revoked, independent of the JWT's own expiry.
|
||||
func GetTokenByAccessTokenHash(_ context.Context, db orm.DB, hash string) (*schema.Token, error) {
|
||||
if hash == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := orm.TypedQuery[schema.Token](db).Filter("AccessTokenHash=", hash).First()
|
||||
if err == orm.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// GetTokenByRefreshHash resolves a token row by the SHA-256 hash of a presented
|
||||
// refresh token — the refresh-grant lookup.
|
||||
func GetTokenByRefreshHash(_ context.Context, db orm.DB, hash string) (*schema.Token, error) {
|
||||
if hash == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := orm.TypedQuery[schema.Token](db).Filter("RefreshTokenHash=", hash).First()
|
||||
if err == orm.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// ListTokensByRefreshFamily returns every row sharing a refresh-token family —
|
||||
// the rotation chain a reuse-detection event revokes as a unit.
|
||||
func ListTokensByRefreshFamily(ctx context.Context, db orm.DB, family string) ([]*schema.Token, error) {
|
||||
if family == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return orm.TypedQuery[schema.Token](db).Filter("RefreshFamily=", family).GetAll(ctx)
|
||||
}
|
||||
|
||||
// DeleteToken removes a token row by (owner, name). A missing row is not an
|
||||
// error — revocation is idempotent.
|
||||
func DeleteToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
|
||||
existing, err := orm.Get[schema.Token](db, tok.Owner+"/"+tok.Name)
|
||||
if err != nil {
|
||||
if err == orm.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return existing.DeleteCtx(ctx)
|
||||
}
|
||||
|
||||
// GetProvider resolves a provider record by (owner, name) — e.g.
|
||||
// ("admin", "provider-github"). Providers are shared org-level records the
|
||||
// application's ProviderItem links to by name.
|
||||
|
||||
Reference in New Issue
Block a user