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:
2026-07-15 03:28:45 -07:00
parent 50c1614976
commit 73b7ef63ea
27 changed files with 3028 additions and 165 deletions
+4 -1
View File
@@ -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
+2
View File
@@ -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=
+175
View File
@@ -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)
}
}
+142
View File
@@ -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
}
+165
View File
@@ -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
}
+6 -1
View File
@@ -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,
+81
View File
@@ -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
}
+164
View File
@@ -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)
}
+85
View File
@@ -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, "-", ""))]
}
+196
View File
@@ -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
View File
@@ -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
View File
@@ -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())
}
+60
View File
@@ -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
}
+150
View File
@@ -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
View File
@@ -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{}})
}
+133
View File
@@ -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
}
+123
View File
@@ -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"])
}
}
+218
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+251
View File
@@ -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
}
+112
View File
@@ -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})
}
+189
View File
@@ -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"])
}
}
+57
View File
@@ -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
}
+5 -7
View File
@@ -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)
+20
View File
@@ -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"`
}
+69
View File
@@ -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.