pkce: move the S256 rule out of internal/ so clients can import it
iam is the authorization server: it decides what a code_challenge is. But
the derivation lived in internal/oidc, which no client can import, so
clients copied the two statements instead. hanzoai/cloud has two such
copies -- apps/deploy/login.go pkceChallenge (whose comment says outright
it is "byte-identical to IAM's own pkceChallenge") and apps/integrations
twitterChallenge, identical apart from the name.
A copy of a transform is not wrong today; it is wrong the first time the
rule changes and only one copy hears about it. The fix is not to keep them
in sync, it is to have one of them.
pkg/pkce now holds the derivation -- outside internal/, so a client can
import it -- and exports Method ("S256") alongside, so a client cannot send
a method this server refuses. internal/oidc's ComputeS256Challenge is gone
and its 33 references, including VerifyPKCE itself, call pkce.Challenge.
Verification policy (constant-time compare, plain permanently rejected, the
sentinel errors) stays in internal/oidc where it belongs: that is the
server's rule, not the primitive.
The RFC 7636 Appendix B vector moves to pkg/pkce with the derivation, and
picks up a test that the encoding is unpadded base64url -- padding or the
standard alphabet yields a challenge the server will not match.
internal/oidc's copy of the vector test is deleted (it pinned the function
that moved); its VerifyPKCE policy tests all stay.
Also: the prose in pkg/schema, internal/oidc and internal/users described a
storage key as "a GenerateID decimal string". hanzoai/orm just unexported
that function, because it sat one keystroke from a UUID generator, so the
comments now describe the value's shape and name no private symbol of
another module. zipdoc_gen.go regenerated; the diff is that sentence only.
0 failing packages before and after.
This commit is contained in:
@@ -127,7 +127,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
@@ -197,7 +197,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
@@ -265,7 +265,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
|
||||
@@ -33,8 +33,8 @@ import (
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/oidc"
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
|
||||
"github.com/hanzoai/iam/internal/testhttp"
|
||||
@@ -223,7 +223,7 @@ func (e *env) login(t *testing.T, verifier string) string {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"type": "code", "organization": "hanzo", "username": "alice@hanzo.ai", "password": "pw",
|
||||
"clientId": "hanzo-console", "redirectUri": redirectURI, "scope": "openid profile email offline_access",
|
||||
"codeChallenge": oidc.ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
"codeChallenge": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
st, resp := e.req(t, "POST", "/v1/iam/login", "", string(body), "application/json")
|
||||
if st != 200 {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
)
|
||||
|
||||
const testRedirect = "https://app.example/callback"
|
||||
@@ -85,7 +87,7 @@ func TestAuthorize_DelegatesValidRequest(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
challenge := ComputeS256Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
|
||||
challenge := pkce.Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {"pub"},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,7 @@ func testApp() *schema.Application {
|
||||
func TestMintCode_BindsPKCEAndExpiry(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-abc-000000000000000000000000000000000"
|
||||
ch := ComputeS256Challenge(verifier)
|
||||
ch := pkce.Challenge(verifier)
|
||||
tok, err := MintCode(testApp(), "hanzo/alice", "openid profile", ch, "S256", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -49,7 +50,7 @@ func TestMintCode_RefusesPlain(t *testing.T) {
|
||||
func TestRedeemCode_HappyPath(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-happy-0000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "hanzo/alice", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "hanzo/alice", "openid", pkce.Challenge(verifier), "S256", "", now)
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, now.Add(30*time.Second)); err != nil {
|
||||
t.Fatalf("valid redemption rejected: %v", err)
|
||||
}
|
||||
@@ -58,7 +59,7 @@ func TestRedeemCode_HappyPath(t *testing.T) {
|
||||
func TestRedeemCode_ReplayRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-replay-000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge(verifier), "S256", "", now)
|
||||
// First redemption + issue marks it used.
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, now); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -75,7 +76,7 @@ func TestRedeemCode_ReplayRejected(t *testing.T) {
|
||||
func TestRedeemCode_ExpiredRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-exp-00000000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge(verifier), "S256", "", now)
|
||||
past := now.Add(codeTTL + time.Second)
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, past); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("expired code: got %v, want ErrCodeExpired", err)
|
||||
@@ -85,7 +86,7 @@ func TestRedeemCode_ExpiredRejected(t *testing.T) {
|
||||
func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-cli-00000000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge(verifier), "S256", "", now)
|
||||
if err := RedeemCode(tok, "some-other-app", verifier, now); !errors.Is(err, ErrClientMismatch) {
|
||||
t.Fatalf("client mismatch: got %v, want ErrClientMismatch", err)
|
||||
}
|
||||
@@ -93,7 +94,7 @@ func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
|
||||
|
||||
func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge("the-right-verifier-0000000000000000000000000"), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge("the-right-verifier-0000000000000000000000000"), "S256", "", now)
|
||||
if err := RedeemCode(tok, "hanzo-console", "the-WRONG-verifier-0000000000000000000000000", now); !errors.Is(err, ErrPKCEMismatch) {
|
||||
t.Fatalf("wrong verifier: got %v, want ErrPKCEMismatch", err)
|
||||
}
|
||||
@@ -102,7 +103,7 @@ func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
|
||||
func TestRedeemCode_PublicClientMustPresentVerifier(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
// Code minted WITH a challenge (public client) but token request omits the verifier.
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge("v-000000000000000000000000000000000000000000000"), "S256", "", now)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", pkce.Challenge("v-000000000000000000000000000000000000000000000"), "S256", "", now)
|
||||
if err := RedeemCode(tok, "hanzo-console", "", now); !errors.Is(err, ErrPKCEMissing) {
|
||||
t.Fatalf("missing verifier: got %v, want ErrPKCEMissing", err)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
@@ -474,7 +475,7 @@ func TestDevice_AuthorizationCodeIsNotRedeemableAsDeviceCode(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"clientId": "hanzo-app", "redirectUri": testRedirect, "scope": "openid",
|
||||
"codeChallenge": ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
"codeChallenge": pkce.Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
if code == "" {
|
||||
t.Fatal("setup: no authorization code minted")
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
)
|
||||
|
||||
@@ -277,7 +278,7 @@ func oidcAuthorizeURL(cfg oidcConfig, p *schema.Provider, st *schema.FederationS
|
||||
v.Set("scope", ensureOpenID(providerScopes(p, "openid email profile")))
|
||||
v.Set("state", st.Name)
|
||||
v.Set("nonce", st.IdpNonce)
|
||||
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge_method", "S256")
|
||||
return joinQuery(cfg.authURL, v)
|
||||
}
|
||||
@@ -377,7 +378,7 @@ func githubAuthorizeURL(p *schema.Provider, st *schema.FederationState, callback
|
||||
v.Set("state", st.Name)
|
||||
v.Set("allow_signup", "true")
|
||||
if p.EnablePkce {
|
||||
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge", pkce.Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge_method", "S256")
|
||||
}
|
||||
return joinQuery(firstNonEmpty(p.CustomAuthUrl, githubAuthorizeEndpoint), v)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
@@ -264,7 +265,7 @@ func beginAuthorize(t *testing.T, app *zip.App, clientID, provider string) (url.
|
||||
"redirect_uri": {testRedirect},
|
||||
"scope": {"openid email profile"},
|
||||
"state": {fedAppState},
|
||||
"code_challenge": {ComputeS256Challenge(fedVerifier)},
|
||||
"code_challenge": {pkce.Challenge(fedVerifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
"provider": {provider},
|
||||
}
|
||||
@@ -700,7 +701,7 @@ func TestFederation_NonAllowlistedRedirectUriRefused(t *testing.T) {
|
||||
q := url.Values{
|
||||
"response_type": {"code"}, "client_id": {"webapp"},
|
||||
"redirect_uri": {"https://evil.example/steal"},
|
||||
"code_challenge": {ComputeS256Challenge(fedVerifier)},
|
||||
"code_challenge": {pkce.Challenge(fedVerifier)},
|
||||
"provider": {fedProvGoogle},
|
||||
}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+q.Encode()))
|
||||
@@ -775,7 +776,7 @@ func federationAuthorizeQuery(clientID string) url.Values {
|
||||
"response_type": {"code"},
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {testRedirect},
|
||||
"code_challenge": {ComputeS256Challenge(fedVerifier)},
|
||||
"code_challenge": {pkce.Challenge(fedVerifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {fedAppState},
|
||||
"provider": {fedProvGoogle},
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
)
|
||||
|
||||
// The `sk-` Cloud API-key primitives (mint/revoke). A confidential, allow-listed
|
||||
@@ -168,7 +168,7 @@ func TestMintUserKeys_forbiddenUser_403(t *testing.T) {
|
||||
// SetId("owner/name"), so the row's storage key HAPPENS to equal "owner/name" and the
|
||||
// pre-fix owner/name-keyed read resolves it. A user minted through the ONE canonical
|
||||
// users.Create path (signup / SCIM / federation / CRUD) gets a store-ASSIGNED surrogate
|
||||
// key (a GenerateID decimal string) and a UUID sub — the exact post-cutover account
|
||||
// key (a decimal string) and a UUID sub — the exact post-cutover account
|
||||
// shape — for which an "owner/name" lookup MISSES. The pre-fix saveUser then errored
|
||||
// (orm.ErrNotFound → 500) and no sk- key was ever minted or revoked for a new signup.
|
||||
// This drives mint AND revoke against a create-path user; it FAILS before the fix (mint
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -123,7 +124,7 @@ func TestLogin_AuthorizePassthroughFromQuery_RFCSpelling(t *testing.T) {
|
||||
"scope": {"openid email"},
|
||||
"state": {"st-1"},
|
||||
"nonce": {"nonce-rfc"},
|
||||
"code_challenge": {ComputeS256Challenge(verifier)},
|
||||
"code_challenge": {pkce.Challenge(verifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
@@ -46,7 +47,7 @@ func TestLoginToTokenFlow(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
|
||||
verifier := "login-verifier-000000000000000000000000000000000"
|
||||
challenge := ComputeS256Challenge(verifier)
|
||||
challenge := pkce.Challenge(verifier)
|
||||
|
||||
// --- login side: resolve app+user, verify password, mint the code ---
|
||||
user, err := resolveLoginUser(ctx, db, "hanzo", "alice@hanzo.ai") // login by EMAIL
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
)
|
||||
|
||||
// The hanzo-cli login, end to end, exactly as the CLI performs it
|
||||
@@ -38,7 +40,7 @@ func TestCLILoopbackPKCEFlow_EndToEnd(t *testing.T) {
|
||||
seedUser(t, db, "z", "z@hanzo.ai", "IloveHanzo2026!!")
|
||||
|
||||
verifier := "KmKyPMK1T4JxydUiDsLmCaz79cqcmYqoBCpaeWWoxrU"
|
||||
challenge := ComputeS256Challenge(verifier)
|
||||
challenge := pkce.Challenge(verifier)
|
||||
|
||||
// 1. authorize — the ephemeral port must be accepted.
|
||||
q := url.Values{
|
||||
@@ -144,7 +146,7 @@ func TestCLILoopback_CodeBoundToExactPort(t *testing.T) {
|
||||
"password": "IloveHanzo2026!!",
|
||||
"clientId": "hanzo-cli",
|
||||
"redirectUri": runtimeRedirect,
|
||||
"codeChallenge": ComputeS256Challenge(verifier),
|
||||
"codeChallenge": pkce.Challenge(verifier),
|
||||
})
|
||||
if code == "" {
|
||||
t.Fatal("no code minted")
|
||||
|
||||
+4
-11
@@ -3,10 +3,10 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
)
|
||||
|
||||
// PKCE (RFC 7636) — S256 only. iam permanently rejects the "plain" method:
|
||||
@@ -27,13 +27,6 @@ var (
|
||||
ErrPKCEMissing = errors.New("pkce: code_verifier required")
|
||||
)
|
||||
|
||||
// ComputeS256Challenge derives the RFC 7636 S256 challenge from a verifier:
|
||||
// BASE64URL-ENCODE(SHA256(ASCII(verifier))), no padding.
|
||||
func ComputeS256Challenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// VerifyPKCE checks a code_verifier against a stored (challenge, method).
|
||||
//
|
||||
// - A stored challenge with method != "S256" is refused (ErrPKCEPlainRejected)
|
||||
@@ -54,13 +47,13 @@ func VerifyPKCE(verifier, challenge, method string) error {
|
||||
}
|
||||
return nil // no PKCE on either side; caller enforces public-client policy
|
||||
}
|
||||
if method != "S256" {
|
||||
if method != pkce.Method {
|
||||
return ErrPKCEPlainRejected
|
||||
}
|
||||
if verifier == "" {
|
||||
return ErrPKCEMissing
|
||||
}
|
||||
want := ComputeS256Challenge(verifier)
|
||||
want := pkce.Challenge(verifier)
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(challenge)) != 1 {
|
||||
return ErrPKCEMismatch
|
||||
}
|
||||
|
||||
@@ -5,27 +5,24 @@ package oidc
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
)
|
||||
|
||||
func TestComputeS256Challenge_RFC7636Vector(t *testing.T) {
|
||||
// The canonical RFC 7636 Appendix B test vector.
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
want := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
if got := ComputeS256Challenge(verifier); got != want {
|
||||
t.Fatalf("S256 challenge = %q, want %q (RFC 7636 vector)", got, want)
|
||||
}
|
||||
}
|
||||
// The RFC 7636 Appendix B vector is pinned where the derivation lives, in
|
||||
// pkg/pkce. These tests cover what is this package's own: the verification
|
||||
// policy around it.
|
||||
|
||||
func TestVerifyPKCE_HappyPath(t *testing.T) {
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge := ComputeS256Challenge(verifier)
|
||||
challenge := pkce.Challenge(verifier)
|
||||
if err := VerifyPKCE(verifier, challenge, "S256"); err != nil {
|
||||
t.Fatalf("valid verifier rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPKCE_WrongVerifierRejected(t *testing.T) {
|
||||
challenge := ComputeS256Challenge("the-real-verifier-value-0000000000000000000")
|
||||
challenge := pkce.Challenge("the-real-verifier-value-0000000000000000000")
|
||||
err := VerifyPKCE("a-different-verifier-value-000000000000000000", challenge, "S256")
|
||||
if !errors.Is(err, ErrPKCEMismatch) {
|
||||
t.Fatalf("wrong verifier: got %v, want ErrPKCEMismatch", err)
|
||||
@@ -43,7 +40,7 @@ func TestVerifyPKCE_PlainRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVerifyPKCE_MissingVerifier(t *testing.T) {
|
||||
challenge := ComputeS256Challenge("some-verifier-0000000000000000000000000000000")
|
||||
challenge := pkce.Challenge("some-verifier-0000000000000000000000000000000")
|
||||
if err := VerifyPKCE("", challenge, "S256"); !errors.Is(err, ErrPKCEMissing) {
|
||||
t.Fatalf("empty verifier with a stored challenge: got %v, want ErrPKCEMissing", err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,7 @@ func grantViaPKCE(t *testing.T, app *zip.App, clientID, scope string) map[string
|
||||
t.Helper()
|
||||
verifier := "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
|
||||
params := loginParams(clientID, scope)
|
||||
params["codeChallenge"] = ComputeS256Challenge(verifier)
|
||||
params["codeChallenge"] = pkce.Challenge(verifier)
|
||||
params["codeChallengeMethod"] = "S256"
|
||||
code, _, _ := loginForCode(t, app, params)
|
||||
resp, tok := exchangeCode(t, app, url.Values{
|
||||
@@ -128,7 +129,7 @@ func grantWithSecret(t *testing.T, app *zip.App, clientID, secret, scope string)
|
||||
t.Helper()
|
||||
verifier := "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
|
||||
params := loginParams(clientID, scope)
|
||||
params["codeChallenge"] = ComputeS256Challenge(verifier)
|
||||
params["codeChallenge"] = pkce.Challenge(verifier)
|
||||
params["codeChallengeMethod"] = "S256"
|
||||
code, _, _ := loginForCode(t, app, params)
|
||||
resp, tok := exchangeCode(t, app, url.Values{
|
||||
|
||||
@@ -13,6 +13,8 @@ package oidc
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
)
|
||||
|
||||
const browserVerifier = "browser-client-verifier-000000000000000000000000"
|
||||
@@ -27,7 +29,7 @@ func TestBrowserClient_PKCECodeNeedsNoSecret(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"clientId": "hanzo-chat", "redirectUri": testRedirect, "scope": "openid",
|
||||
"codeChallenge": ComputeS256Challenge(browserVerifier), "codeChallengeMethod": "S256",
|
||||
"codeChallenge": pkce.Challenge(browserVerifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
if code == "" {
|
||||
t.Fatal("setup: no authorization code minted")
|
||||
@@ -52,7 +54,7 @@ func TestBrowserClient_WrongSecretStillFails(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"clientId": "hanzo-chat", "redirectUri": testRedirect, "scope": "openid",
|
||||
"codeChallenge": ComputeS256Challenge(browserVerifier), "codeChallengeMethod": "S256",
|
||||
"codeChallenge": pkce.Challenge(browserVerifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
|
||||
resp, m := exchangeCode(t, app, url.Values{
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/hanzoai/iam/pkg/schema"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
@@ -79,7 +80,7 @@ func TestTokenExchange_EndToEnd(t *testing.T) {
|
||||
|
||||
// --- authorize side: mint a PKCE-bound code and persist it ---
|
||||
verifier := "e2e-verifier-000000000000000000000000000000000000"
|
||||
code, err := MintCode(app, "hanzo/alice", "openid profile", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
code, err := MintCode(app, "hanzo/alice", "openid profile", pkce.Challenge(verifier), "S256", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/pkce"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -91,7 +92,7 @@ func TestAuthCodeFlow_PublicPKCE(t *testing.T) {
|
||||
|
||||
verifier := "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
|
||||
params := loginParams("pub", "openid")
|
||||
params["codeChallenge"] = ComputeS256Challenge(verifier)
|
||||
params["codeChallenge"] = pkce.Challenge(verifier)
|
||||
params["codeChallengeMethod"] = "S256"
|
||||
|
||||
t.Run("valid verifier", func(t *testing.T) {
|
||||
|
||||
@@ -22,8 +22,8 @@ import (
|
||||
//
|
||||
// - RIGHT KEY, BOTH SHAPES. The storage key is resolved via the (owner,name) query
|
||||
// path, which stamps the row's REAL orm key: "owner/name" for a migrated legacy
|
||||
// row (SetId in the migrator), or a store-assigned surrogate id (a GenerateID
|
||||
// decimal string) for a v2-native users.Create'd row (Create allocates, it never
|
||||
// row (SetId in the migrator), or a store-assigned surrogate id (a decimal
|
||||
// string) for a v2-native users.Create'd row (Create allocates, it never
|
||||
// pins the key). A plain orm.Get(owner+"/"+name) resolves ONLY migrated rows, so a
|
||||
// post-cutover signup was missed and its hk- key mint/revoke silently 500'd. The
|
||||
// lock is taken by that exact resolved key, so both shapes are written.
|
||||
|
||||
@@ -96,7 +96,7 @@ func Authenticate(ctx context.Context, db orm.DB, user *schema.User, password, o
|
||||
// (internal/wallet/store.go), the established row-lock pattern in this repo.
|
||||
//
|
||||
// The row's storage key is resolved ONCE via the (owner,name) query path (which stamps
|
||||
// the real orm key — a store-assigned surrogate id, a GenerateID decimal string like
|
||||
// the real orm key — a store-assigned surrogate id, a decimal string like
|
||||
// "17847909129933610000001", for a v2-native users.Create'd row; "owner/name" for a
|
||||
// migrated legacy row), then the lock is taken by that exact key. Reading the FRESH row and
|
||||
// writing it back under the held lock keeps recordAttempt's OWN write free of lost
|
||||
|
||||
@@ -23,7 +23,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
@@ -47,7 +47,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
@@ -71,7 +71,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
@@ -98,7 +98,7 @@ func init() {
|
||||
"User.createdIp": "Sign-in provenance.",
|
||||
"User.displayName": "Profile.",
|
||||
"User.github": "Linked federated-identity subjects, one column per connector (v1 parity).",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a GenerateID decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.id": "Id is the user's STABLE OPAQUE identifier — the value the OIDC `sub` claim\ncarries. It is the v1 the legacy surface per-row UUID (e.g.\n\"e7d7fda0-4c53-4508-9d35-7ec892b7e5d7\"), migrated verbatim so a user's `sub`\nis byte-identical across the cutover: every live session, external reference,\nand the downstream money-path principal keyed on `sub` survive unchanged. A\nuser minted natively in v2 is assigned a fresh UUID here on create, so the\n`sub` is ALWAYS a stable opaque id going forward — never the (Owner, Name)\npair, which is mutable (a rename would otherwise silently reissue identity).\n\nIt is distinct from the embedded orm.Model STORAGE KEY — the value the datastore\nlocks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED\nlegacy row is stamped \"owner/name\" (SetId in the migrator), but a v2-native\nusers.Create'd row is NOT — Create allocates rather than pinning a key, so its\nstorage key is a store-assigned surrogate id (a decimal string like\n\"17847909129933610000001\"). (Owner, Name) is therefore the natural/QUERY key\n(unique, indexed), not necessarily the storage key: resolve a row for a locked\nwrite by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both\nshapes — see internal/oidc updateUser), never by assuming \"owner/name\". This Id is\na first-class, indexed DOMAIN field; its json tag \"id\" dominates the promoted\norm.Model `Id_` (also \"id\") by shallower depth, so the persisted record's \"id\" is\nthis UUID — exactly the v1 shape. A row that carries no Id (a not-yet-assigned\npre-cutover user) falls back to the (Owner, Name) subject at mint; every other\npath resolves `sub`→user by Id.",
|
||||
"User.isDefaultAvatar": "State flags.",
|
||||
"User.owner": "Identity / tenancy. (Owner, Name) is the natural key.",
|
||||
"User.passwordHash": "Credential material. PasswordHash is a one-way bcrypt digest and is\nverify-only. It MUST be persisted (orm serializes the entity to its JSON\ndata column, so a json:\"-\" field would never be stored — that silently\nbroke login), so it carries a real json tag; the users API redact() strips\nit (and every other secret) from every response. PasswordType and\nPasswordSalt describe the digest scheme so rows hashed under the legacy\nargon2id scheme can still be verified and lazily re-hashed to bcrypt.",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package pkce is the RFC 7636 S256 transform, in one importable place.
|
||||
//
|
||||
// It exists because iam is the authorization server: it decides what a
|
||||
// code_challenge is, and every client that sends one has to derive it by the
|
||||
// server's rule. When the rule lives inside iam's internal/ tree, a client
|
||||
// cannot import it and copies the two statements instead -- which is exactly
|
||||
// what happened, twice over in hanzoai/cloud. Copies of a transform do not
|
||||
// stay honest for free; they stay honest because nobody has changed the rule
|
||||
// yet.
|
||||
//
|
||||
// So the derivation lives here, outside internal/, and both sides call it.
|
||||
package pkce
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// Method is the only code_challenge_method iam accepts.
|
||||
//
|
||||
// "plain" is permanently refused: under plain the challenge IS the verifier,
|
||||
// so it travels in the clear on the authorize leg and PKCE stops protecting
|
||||
// anything. Clients should send this constant rather than their own literal,
|
||||
// so a client cannot be pointed at a method the server will reject.
|
||||
const Method = "S256"
|
||||
|
||||
// Challenge derives the RFC 7636 code_challenge from a verifier:
|
||||
// BASE64URL-ENCODE(SHA256(ASCII(verifier))), without padding.
|
||||
//
|
||||
// Unpadded is not a detail -- RFC 7636 section 4.2 specifies base64url with
|
||||
// the trailing '=' removed, so a padded encoding produces a challenge the
|
||||
// server will not match.
|
||||
func Challenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package pkce
|
||||
|
||||
import "testing"
|
||||
|
||||
// The canonical RFC 7636 Appendix B vector. This is the contract every client
|
||||
// derives against, so it is pinned where the derivation lives rather than
|
||||
// separately in each caller.
|
||||
func TestChallengeMatchesRFC7636Vector(t *testing.T) {
|
||||
const (
|
||||
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
want = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
)
|
||||
if got := Challenge(verifier); got != want {
|
||||
t.Fatalf("Challenge = %q, want %q (RFC 7636 Appendix B)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The encoding must be base64url WITHOUT padding, and must not use the
|
||||
// standard alphabet: a '+' or '/' in a query parameter, or a trailing '=',
|
||||
// produces a challenge the server will not match.
|
||||
func TestChallengeIsUnpaddedBase64URL(t *testing.T) {
|
||||
// This verifier hashes to a digest containing bytes that encode to '-'
|
||||
// and '_' under base64url and to '+' and '/' under the standard alphabet.
|
||||
for _, verifier := range []string{"", "a", "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"} {
|
||||
got := Challenge(verifier)
|
||||
if len(got) != 43 {
|
||||
t.Errorf("Challenge(%q) is %d chars, want 43 (unpadded 256-bit digest)", verifier, len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
ok := r == '-' || r == '_' ||
|
||||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||
if !ok {
|
||||
t.Errorf("Challenge(%q) = %q contains %q, outside the base64url alphabet", verifier, got, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method is what clients send; if it ever stopped being S256 the server would
|
||||
// reject every request that trusted it.
|
||||
func TestMethodIsS256(t *testing.T) {
|
||||
if Method != "S256" {
|
||||
t.Fatalf("Method = %q, want S256", Method)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ type User struct {
|
||||
// locks and looks a row up by — which is NOT (Owner, Name) for every row: a MIGRATED
|
||||
// legacy row is stamped "owner/name" (SetId in the migrator), but a v2-native
|
||||
// users.Create'd row is NOT — Create allocates rather than pinning a key, so its
|
||||
// storage key is a store-assigned surrogate id (a GenerateID decimal string like
|
||||
// storage key is a store-assigned surrogate id (a decimal string like
|
||||
// "17847909129933610000001"). (Owner, Name) is therefore the natural/QUERY key
|
||||
// (unique, indexed), not necessarily the storage key: resolve a row for a locked
|
||||
// write by its REAL key (store.GetUserByName(...).Key().Encode(), which stamps both
|
||||
|
||||
Reference in New Issue
Block a user