@ultimat3/auth 30.8.0 → 31.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CLAUDE.md +55 -109
  2. package/README.md +100 -25
  3. package/package.json +5 -4
  4. package/src/adapter-subpath.ts +34 -0
  5. package/src/api-keys.ts +14 -1
  6. package/src/auth.ts +3 -3
  7. package/src/authorization-server-clients.ts +19 -1
  8. package/src/authorization-server-config.ts +13 -4
  9. package/src/authorization-server-installed.ts +34 -0
  10. package/src/authorization-server-postgres.ts +4 -2
  11. package/src/authorization-server-store.ts +14 -4
  12. package/src/authorization-server.ts +30 -10
  13. package/src/client-scope.ts +1 -1
  14. package/src/errors.ts +9 -1
  15. package/src/grant-authorization-code.ts +21 -16
  16. package/src/grant-device.ts +2 -2
  17. package/src/identity-provider-cookie.ts +89 -0
  18. package/src/identity-provider-oidc.ts +142 -0
  19. package/src/identity-provider-route-core.ts +253 -0
  20. package/src/identity-provider-routes.ts +78 -0
  21. package/src/identity-provider-status.ts +45 -0
  22. package/src/identity-provider.ts +112 -0
  23. package/src/index.ts +34 -54
  24. package/src/jwks.ts +2 -2
  25. package/src/kdf-gate.ts +24 -14
  26. package/src/limiter-install.ts +26 -16
  27. package/src/memory-adapter.ts +5 -5
  28. package/src/mfa.ts +1 -1
  29. package/src/oauth-discovery.ts +1 -1
  30. package/src/oauth-errors.ts +26 -2
  31. package/src/oauth-exchange.ts +2 -2
  32. package/src/oauth-paths.ts +5 -2
  33. package/src/oauth-route.ts +60 -297
  34. package/src/rate-limit.ts +7 -2
  35. package/src/saml-assertion.ts +156 -0
  36. package/src/saml-c14n.ts +118 -0
  37. package/src/saml-certificate.ts +47 -0
  38. package/src/saml-dom.ts +66 -0
  39. package/src/saml-dsig.ts +165 -0
  40. package/src/saml-email-trust.ts +49 -0
  41. package/src/saml-errors.ts +96 -0
  42. package/src/saml-metadata.ts +80 -0
  43. package/src/saml-provider.ts +179 -0
  44. package/src/saml-request.ts +51 -0
  45. package/src/saml-response.ts +131 -0
  46. package/src/saml-xml.ts +328 -0
@@ -0,0 +1,118 @@
1
+ // Single responsibility: Exclusive XML Canonicalization 1.0 without comments over one element's
2
+ // subtree — the bytes a SAML signature covers. Comments are dropped, which is why a reader taking
3
+ // only a NameID's FIRST text node is fooled by `ada@corp.com<!---->.evil.com`: the signature
4
+ // covers the concatenation, so `saml-xml.ts` readers concatenate too.
5
+
6
+ import type { XmlAttribute, XmlElement } from './saml-xml';
7
+
8
+ export interface C14nOptions {
9
+ /** The enveloped `ds:Signature`: it and its subtree are not part of the node set. */
10
+ readonly exclude?: XmlElement | undefined;
11
+ /** `InclusiveNamespaces PrefixList`; `#default` names the default namespace. */
12
+ readonly inclusivePrefixes?: readonly string[] | undefined;
13
+ }
14
+
15
+ /** `&name;` — one XML entity or character reference, as Exclusive C14N §2.3 writes it. */
16
+ const ref = (name: string): string => `&${name};`;
17
+
18
+ /**
19
+ * C14N's own tables, NOT core's `escapeHtml`: the spec fixes them byte for byte — `>` escaped in
20
+ * text but never in an attribute, `'` never, and CR/TAB/LF as `&#xD;`/`&#x9;`/`&#xA;` — and one
21
+ * byte off is a digest mismatch. `escapeHtml` is right for markup and wrong here.
22
+ */
23
+ const TEXT_REFS: ReadonlyMap<string, string> = new Map([
24
+ ['&', ref('amp')],
25
+ ['<', ref('lt')],
26
+ ['>', ref('gt')],
27
+ ['\r', ref('#xD')],
28
+ ]);
29
+ const ATTRIBUTE_REFS: ReadonlyMap<string, string> = new Map([
30
+ ['&', ref('amp')],
31
+ ['<', ref('lt')],
32
+ ['"', ref('quot')],
33
+ ['\t', ref('#x9')],
34
+ ['\n', ref('#xA')],
35
+ ['\r', ref('#xD')],
36
+ ]);
37
+
38
+ /** A text node in canonical form. */
39
+ export const c14nText = (value: string): string =>
40
+ value.replace(/[&<>\r]/g, (char) => TEXT_REFS.get(char) ?? char);
41
+
42
+ /** An attribute value in canonical form, for between double quotes. */
43
+ export const c14nAttribute = (value: string): string =>
44
+ value.replace(/[&<"\t\n\r]/g, (char) => ATTRIBUTE_REFS.get(char) ?? char);
45
+
46
+ /** Code-unit order, which for the ASCII names SAML uses is the spec's code-point order. */
47
+ const byKey = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
48
+
49
+ /**
50
+ * The prefixes this element visibly utilises: its own, and its prefixed attributes' (`xml:` is
51
+ * never declared). An InclusiveNamespaces prefix is rendered by the inclusive rule instead — when
52
+ * it is in scope at all.
53
+ */
54
+ function wanted(element: XmlElement, inclusive: ReadonlySet<string>): Set<string> {
55
+ const prefixes = new Set<string>([element.prefix ?? '']);
56
+ for (const attribute of element.attributes) {
57
+ if (attribute.prefix !== null && attribute.prefix !== 'xml') prefixes.add(attribute.prefix);
58
+ }
59
+ for (const prefix of inclusive) if (element.scope.has(prefix)) prefixes.add(prefix);
60
+ return prefixes;
61
+ }
62
+
63
+ function namespaces(
64
+ element: XmlElement,
65
+ rendered: Map<string, string>,
66
+ inclusive: ReadonlySet<string>,
67
+ ): string {
68
+ const out: [string, string][] = [];
69
+ for (const prefix of wanted(element, inclusive)) {
70
+ const uri = element.scope.get(prefix) ?? '';
71
+ // An empty default is only worth saying when an output ancestor said a non-empty one.
72
+ const previous = rendered.get(prefix) ?? '';
73
+ if (prefix === '' ? uri === previous : rendered.get(prefix) === uri) continue;
74
+ rendered.set(prefix, uri);
75
+ out.push([prefix, uri]);
76
+ }
77
+ out.sort((a, b) => byKey(a[0], b[0]));
78
+ return out
79
+ .map(
80
+ ([prefix, uri]) => ` ${prefix === '' ? 'xmlns' : `xmlns:${prefix}`}="${c14nAttribute(uri)}"`,
81
+ )
82
+ .join('');
83
+ }
84
+
85
+ const attributeOrder = (a: XmlAttribute, b: XmlAttribute): number =>
86
+ byKey(a.ns, b.ns) || byKey(a.local, b.local);
87
+
88
+ function render(
89
+ element: XmlElement,
90
+ rendered: ReadonlyMap<string, string>,
91
+ options: C14nOptions,
92
+ inclusive: ReadonlySet<string>,
93
+ out: string[],
94
+ ): void {
95
+ const scope = new Map(rendered);
96
+ out.push(`<${element.qname}${namespaces(element, scope, inclusive)}`);
97
+ for (const attribute of [...element.attributes].sort(attributeOrder)) {
98
+ out.push(` ${attribute.qname}="${c14nAttribute(attribute.value)}"`);
99
+ }
100
+ out.push('>');
101
+ for (const child of element.children) {
102
+ if (child.kind === 'text') out.push(c14nText(child.value));
103
+ else if (child.kind === 'element' && child !== options.exclude) {
104
+ render(child, scope, options, inclusive, out);
105
+ }
106
+ }
107
+ out.push(`</${element.qname}>`);
108
+ }
109
+
110
+ /** The canonical form of `apex`'s subtree, as the string whose UTF-8 bytes are digested. */
111
+ export function exclusiveC14n(apex: XmlElement, options: C14nOptions = {}): string {
112
+ const inclusive = new Set(
113
+ (options.inclusivePrefixes ?? []).map((prefix) => (prefix === '#default' ? '' : prefix)),
114
+ );
115
+ const out: string[] = [];
116
+ render(apex, new Map(), options, inclusive, out);
117
+ return out.join('');
118
+ }
@@ -0,0 +1,47 @@
1
+ // Single responsibility: an IdP's X.509 signing certificate (base64 DER, as metadata carries it)
2
+ // → the RSA public key Web Crypto verifies with, its `subjectPublicKeyInfo` lifted by core's
3
+ // `x509Spki` (Web Crypto takes no certificate).
4
+ // Only the key is trusted: the certificate's dates and issuer are the metadata's business.
5
+
6
+ import { x509Spki } from '@ultimat3/core';
7
+
8
+ /** NIST SP 800-131A: an RSA key under 2048 bits signs nothing an SP should believe. */
9
+ export const MIN_SAML_RSA_BITS = 2048;
10
+
11
+ /** Strict base64 with the white space metadata wraps it in; `undefined` for anything else. */
12
+ export function base64Bytes(text: string): Uint8Array<ArrayBuffer> | undefined {
13
+ const compact = text.replace(/[ \t\r\n]+/g, '');
14
+ if (compact === '' || !/^[A-Za-z0-9+/]+={0,2}$/.test(compact) || compact.length % 4 !== 0) {
15
+ return undefined;
16
+ }
17
+ const binary = atob(compact);
18
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
19
+ }
20
+
21
+ /** The certificate's `subjectPublicKeyInfo` (core's one DER walk), or `undefined`. */
22
+ export function certificateSpki(base64Der: string): Uint8Array<ArrayBuffer> | undefined {
23
+ const der = base64Bytes(base64Der);
24
+ return der === undefined ? undefined : x509Spki(der);
25
+ }
26
+
27
+ /**
28
+ * The verification key for RSA-SHA256, or `undefined` for a certificate that is unreadable, not
29
+ * RSA, or under `MIN_SAML_RSA_BITS`. The caller names the refusal; this file throws nothing.
30
+ */
31
+ export async function samlVerifyKey(base64Der: string): Promise<CryptoKey | undefined> {
32
+ const spki = certificateSpki(base64Der);
33
+ if (spki === undefined) return undefined;
34
+ try {
35
+ const key = await crypto.subtle.importKey(
36
+ 'spki',
37
+ spki,
38
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
39
+ false,
40
+ ['verify'],
41
+ );
42
+ const { modulusLength } = key.algorithm as RsaHashedKeyAlgorithm;
43
+ return modulusLength >= MIN_SAML_RSA_BITS ? key : undefined;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
@@ -0,0 +1,66 @@
1
+ // Single responsibility: reading a parsed SAML tree — the namespaces and algorithm URIs SAML
2
+ // speaks, and the four lookups every reader needs. Matched by namespace URI and local name, never
3
+ // by prefix: a prefix is the sender's choice, and matching `saml:` would let `evil:Assertion`
4
+ // bound to the same URI walk past a check, or `saml:` bound to another URI walk into one.
5
+
6
+ import type { XmlElement } from './saml-xml';
7
+
8
+ export const SAMLP_NS = 'urn:oasis:names:tc:SAML:2.0:protocol';
9
+ export const SAML_NS = 'urn:oasis:names:tc:SAML:2.0:assertion';
10
+ export const MD_NS = 'urn:oasis:names:tc:SAML:2.0:metadata';
11
+ export const DSIG_NS = 'http://www.w3.org/2000/09/xmldsig#';
12
+ export const EXC_C14N = 'http://www.w3.org/2001/10/xml-exc-c14n#';
13
+ export const ENVELOPED = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature';
14
+ export const RSA_SHA256 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
15
+ export const SHA256 = 'http://www.w3.org/2001/04/xmlenc#sha256';
16
+ export const BINDING_REDIRECT = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect';
17
+ export const BINDING_POST = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST';
18
+ export const STATUS_SUCCESS = 'urn:oasis:names:tc:SAML:2.0:status:Success';
19
+ export const BEARER = 'urn:oasis:names:tc:SAML:2.0:cm:bearer';
20
+ export const NAMEID_PERSISTENT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
21
+ export const NAMEID_TRANSIENT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient';
22
+ export const NAMEID_EMAIL = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress';
23
+
24
+ export const isElement = (element: XmlElement, ns: string, local: string): boolean =>
25
+ element.ns === ns && element.local === local;
26
+
27
+ export function childElements(element: XmlElement, ns?: string, local?: string): XmlElement[] {
28
+ const out: XmlElement[] = [];
29
+ for (const child of element.children) {
30
+ if (child.kind !== 'element') continue;
31
+ if (ns !== undefined && local !== undefined && !isElement(child, ns, local)) continue;
32
+ out.push(child);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /** Every element below `element`, in document order, `element` excluded. */
38
+ export function descendants(element: XmlElement): XmlElement[] {
39
+ const out: XmlElement[] = [];
40
+ const walk = (node: XmlElement): void => {
41
+ for (const child of childElements(node)) {
42
+ out.push(child);
43
+ walk(child);
44
+ }
45
+ };
46
+ walk(element);
47
+ return out;
48
+ }
49
+
50
+ /** An unqualified attribute — SAML's own attributes are never namespaced. */
51
+ export function attribute(element: XmlElement, local: string): string | undefined {
52
+ return element.attributes.find((one) => one.ns === '' && one.local === local)?.value;
53
+ }
54
+
55
+ /**
56
+ * The element's text: EVERY text child, concatenated — what the signature covered, since c14n
57
+ * drops comments. `undefined` when it holds an element, which no SAML leaf value does.
58
+ */
59
+ export function textOf(element: XmlElement): string | undefined {
60
+ let text = '';
61
+ for (const child of element.children) {
62
+ if (child.kind === 'element') return undefined;
63
+ if (child.kind === 'text') text += child.value;
64
+ }
65
+ return text;
66
+ }
@@ -0,0 +1,165 @@
1
+ // Single responsibility: verify the ONE enveloped XML-DSig signature that is a direct child of an
2
+ // element, over that element alone. Wrapping makes the verifier and the reader look at two nodes;
3
+ // here there is no lookup to fool — the caller hands in the node it will read, the Reference must
4
+ // name THAT node's ID, and the key comes from metadata, never from the document's KeyInfo.
5
+
6
+ import { exclusiveC14n } from './saml-c14n';
7
+ import { base64Bytes } from './saml-certificate';
8
+ import {
9
+ attribute,
10
+ childElements,
11
+ DSIG_NS,
12
+ ENVELOPED,
13
+ EXC_C14N,
14
+ isElement,
15
+ RSA_SHA256,
16
+ SHA256,
17
+ textOf,
18
+ } from './saml-dom';
19
+ import type { SamlCheck } from './saml-errors';
20
+ import type { XmlElement } from './saml-xml';
21
+
22
+ export type DsigVerdict =
23
+ | { readonly ok: true }
24
+ | { readonly ok: false; readonly check: SamlCheck; readonly detail: string };
25
+
26
+ const fail = (check: SamlCheck, detail: string): DsigVerdict => ({ ok: false, check, detail });
27
+
28
+ /** The ds:* element children, white space skipped; any other child is a refusal. */
29
+ function dsChildren(element: XmlElement): XmlElement[] | undefined {
30
+ for (const child of element.children) {
31
+ if (child.kind === 'text' && child.value.trim() !== '') return undefined;
32
+ if (child.kind === 'element' && child.ns !== DSIG_NS) return undefined;
33
+ }
34
+ return childElements(element);
35
+ }
36
+
37
+ /** The algorithm, plus the `InclusiveNamespaces PrefixList` exc-c14n allows under it. */
38
+ function c14nPrefixes(method: XmlElement): readonly string[] | undefined {
39
+ if (attribute(method, 'Algorithm') !== EXC_C14N) return undefined;
40
+ const inner = childElements(method);
41
+ if (inner.length === 0) return [];
42
+ const only = inner[0];
43
+ if (inner.length !== 1 || only === undefined || only.ns !== EXC_C14N) return undefined;
44
+ if (only.local !== 'InclusiveNamespaces') return undefined;
45
+ return (attribute(only, 'PrefixList') ?? '').split(/[ \t\n]+/).filter((p) => p !== '');
46
+ }
47
+
48
+ interface ReferenceShape {
49
+ readonly prefixes: readonly string[];
50
+ readonly digest: Uint8Array;
51
+ }
52
+
53
+ function readReference(reference: XmlElement, signed: XmlElement): ReferenceShape | DsigVerdict {
54
+ const id = attribute(signed, 'ID');
55
+ if (id === undefined || id === '') return fail('reference', 'the signed element carries no ID');
56
+ if (attribute(reference, 'URI') !== `#${id}`) {
57
+ return fail('reference', 'the Reference URI does not name the element it is enveloped in');
58
+ }
59
+ const parts = dsChildren(reference);
60
+ const [transforms, method, value] = parts ?? [];
61
+ if (
62
+ parts?.length !== 3 ||
63
+ transforms === undefined ||
64
+ method === undefined ||
65
+ value === undefined
66
+ ) {
67
+ return fail('reference', 'the Reference is not Transforms, DigestMethod, DigestValue');
68
+ }
69
+ if (!isElement(transforms, DSIG_NS, 'Transforms')) return fail('reference', 'no Transforms');
70
+ // Exactly enveloped-signature then exc-c14n: an XPath or XSLT transform is how a signature is
71
+ // made to cover a different node set than the one the reader reads.
72
+ const steps = dsChildren(transforms) ?? [];
73
+ const [first, second] = steps;
74
+ if (steps.length !== 2 || first === undefined || second === undefined) {
75
+ return fail('algorithm', 'the transforms are not enveloped-signature then exc-c14n');
76
+ }
77
+ const prefixes = c14nPrefixes(second);
78
+ if (attribute(first, 'Algorithm') !== ENVELOPED || childElements(first).length > 0) {
79
+ return fail('algorithm', 'the first transform is not enveloped-signature');
80
+ }
81
+ if (prefixes === undefined) return fail('algorithm', 'the second transform is not exc-c14n');
82
+ if (!isElement(method, DSIG_NS, 'DigestMethod') || attribute(method, 'Algorithm') !== SHA256) {
83
+ return fail('algorithm', 'the digest is not SHA-256');
84
+ }
85
+ const digest = isElement(value, DSIG_NS, 'DigestValue')
86
+ ? base64Bytes(textOf(value) ?? '')
87
+ : undefined;
88
+ return digest === undefined
89
+ ? fail('reference', 'the DigestValue is not base64')
90
+ : { prefixes, digest };
91
+ }
92
+
93
+ const equalBytes = (a: Uint8Array, b: Uint8Array): boolean =>
94
+ a.length === b.length && a.every((byte, index) => byte === b[index]);
95
+
96
+ const utf8 = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(text);
97
+
98
+ /**
99
+ * Verify `signed`'s own enveloped signature against the metadata keys. `{ ok: false }` names the
100
+ * check; `signature-missing` when the element carries none (the caller decides if that is fatal).
101
+ */
102
+ export async function verifyEnvelopedSignature(
103
+ signed: XmlElement,
104
+ keys: readonly CryptoKey[],
105
+ ): Promise<DsigVerdict> {
106
+ const signatures = childElements(signed, DSIG_NS, 'Signature');
107
+ const signature = signatures[0];
108
+ if (signature === undefined) return fail('signature-missing', 'the element carries no signature');
109
+ if (signatures.length > 1) return fail('structure', 'the element carries two signatures');
110
+ // SignedInfo, SignatureValue, then an optional KeyInfo that is never read. A ds:Object is where
111
+ // wrapping attacks hide the original assertion, so its presence alone is a refusal.
112
+ const parts = dsChildren(signature) ?? [];
113
+ const [signedInfo, signatureValue, keyInfo] = parts;
114
+ if (
115
+ signedInfo === undefined ||
116
+ signatureValue === undefined ||
117
+ !isElement(signedInfo, DSIG_NS, 'SignedInfo') ||
118
+ !isElement(signatureValue, DSIG_NS, 'SignatureValue') ||
119
+ parts.length > 3 ||
120
+ (keyInfo !== undefined && !isElement(keyInfo, DSIG_NS, 'KeyInfo'))
121
+ ) {
122
+ return fail('structure', 'the Signature is not SignedInfo, SignatureValue[, KeyInfo]');
123
+ }
124
+ const info = dsChildren(signedInfo) ?? [];
125
+ const [c14nMethod, signatureMethod, reference] = info;
126
+ if (
127
+ info.length !== 3 ||
128
+ c14nMethod === undefined ||
129
+ signatureMethod === undefined ||
130
+ reference === undefined
131
+ ) {
132
+ return fail('reference', 'SignedInfo must hold exactly one Reference');
133
+ }
134
+ const infoPrefixes = isElement(c14nMethod, DSIG_NS, 'CanonicalizationMethod')
135
+ ? c14nPrefixes(c14nMethod)
136
+ : undefined;
137
+ if (infoPrefixes === undefined) return fail('algorithm', 'SignedInfo is not exc-c14n');
138
+ if (
139
+ !isElement(signatureMethod, DSIG_NS, 'SignatureMethod') ||
140
+ attribute(signatureMethod, 'Algorithm') !== RSA_SHA256 ||
141
+ childElements(signatureMethod).length > 0
142
+ ) {
143
+ return fail('algorithm', 'the signature is not RSA-SHA256');
144
+ }
145
+ if (!isElement(reference, DSIG_NS, 'Reference')) return fail('reference', 'no Reference');
146
+ const shape = readReference(reference, signed);
147
+ if ('ok' in shape) return shape;
148
+
149
+ const value = base64Bytes(textOf(signatureValue) ?? '');
150
+ if (value === undefined) return fail('signature', 'the SignatureValue is not base64');
151
+ const canonicalInfo = utf8(exclusiveC14n(signedInfo, { inclusivePrefixes: infoPrefixes }));
152
+ let verified = false;
153
+ for (const key of keys) {
154
+ if (await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, value, canonicalInfo)) verified = true;
155
+ }
156
+ if (!verified) return fail('signature', 'no metadata certificate verifies the SignedInfo');
157
+
158
+ const canonical = exclusiveC14n(signed, {
159
+ exclude: signature,
160
+ inclusivePrefixes: shape.prefixes,
161
+ });
162
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', utf8(canonical)));
163
+ if (!equalBytes(digest, shape.digest)) return fail('digest', 'the signed content was altered');
164
+ return { ok: true };
165
+ }
@@ -0,0 +1,49 @@
1
+ // Single responsibility: which asserted addresses a SAML IdP may vouch for. An IdP is one tenant's
2
+ // directory, and its admin can assert ANY address — so "trust this IdP's email" without a domain
3
+ // is "let one customer's admin sign in as another customer's verified user" (`resolveUser` links a
4
+ // verified provider address to the existing account). Trust is therefore per domain, exact.
5
+
6
+ import { renderCauseValue } from '@ultimat3/core';
7
+ import { AuthError } from './errors';
8
+
9
+ /** The trust half of `SamlIdentityProviderConfig`: `true` cannot be written without its domains. */
10
+ export type SamlEmailTrust =
11
+ | {
12
+ /** The IdP's addresses are never treated as verified: they cannot link or create accounts. */
13
+ readonly trustEmail: false;
14
+ readonly emailDomains?: undefined;
15
+ }
16
+ | {
17
+ /** Addresses in `emailDomains` are verified; any other is not, and cannot link. */
18
+ readonly trustEmail: true;
19
+ /** The domains this IdP owns, exactly — a subdomain is listed on its own. Never empty. */
20
+ readonly emailDomains: readonly string[];
21
+ };
22
+
23
+ /** A DNS name: labels of letters, digits and inner hyphens. No wildcard, no trailing dot. */
24
+ const DOMAIN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/;
25
+
26
+ /** The screened, lower-cased domain set; refuses at construction, never at the first login. */
27
+ export function samlTrustedDomains(provider: string, trust: SamlEmailTrust): ReadonlySet<string> {
28
+ if (!trust.trustEmail) return new Set();
29
+ const domains = (trust.emailDomains ?? []).map((domain) => domain.trim().toLowerCase());
30
+ const bad = domains.find((domain) => !DOMAIN.test(domain));
31
+ if (domains.length === 0 || bad !== undefined) {
32
+ throw new AuthError({
33
+ code: 'X_CONFIG_INVALID',
34
+ cause:
35
+ bad === undefined
36
+ ? `samlIdentityProvider ${renderCauseValue(provider)} trusts asserted addresses with no emailDomains, so its admin could assert any tenant's address`
37
+ : `samlIdentityProvider ${renderCauseValue(provider)} lists ${renderCauseValue(bad)} in emailDomains, which is not a plain domain`,
38
+ fix: "samlIdentityProvider({ trustEmail: true, emailDomains: ['corp.example'] }) // the exact domains this IdP owns, or trustEmail: false",
39
+ meta: { provider },
40
+ });
41
+ }
42
+ return new Set(domains);
43
+ }
44
+
45
+ /** Whether `email`'s domain is one the IdP owns. Only the part after the LAST `@` is a domain. */
46
+ export function isTrustedAddress(email: string, domains: ReadonlySet<string>): boolean {
47
+ const at = email.lastIndexOf('@');
48
+ return at > 0 && domains.has(email.slice(at + 1).toLowerCase());
49
+ }
@@ -0,0 +1,96 @@
1
+ // Single responsibility: the SAML refusals. One code for every check a response can fail —
2
+ // `X_SAML_RESPONSE_INVALID` with the check in `meta.check` — because the operator needs to know
3
+ // WHICH check (an audience mismatch is the commonest setup bug there is) and the anonymous caller
4
+ // gets none of it: the route serves the code and its title only, like the OAuth legs.
5
+
6
+ import { renderCauseValue } from '@ultimat3/core';
7
+ import { AuthError } from './errors';
8
+
9
+ /** Every check a response is put through, in the order `verifySamlResponse` runs them. */
10
+ export type SamlCheck =
11
+ | 'encoding'
12
+ | 'xml'
13
+ | 'structure'
14
+ | 'id-duplicate'
15
+ | 'encrypted'
16
+ | 'signature-missing'
17
+ | 'algorithm'
18
+ | 'reference'
19
+ | 'signature'
20
+ | 'digest'
21
+ | 'status'
22
+ | 'issuer'
23
+ | 'destination'
24
+ | 'in-response-to'
25
+ | 'recipient'
26
+ | 'audience'
27
+ | 'not-before'
28
+ | 'expired'
29
+ | 'subject'
30
+ | 'relay-state'
31
+ | 'replay';
32
+
33
+ const SP = 'samlIdentityProvider({ id, idp: parseSamlMetadata(xml), spEntityId, trustEmail })';
34
+ const IDP_APP = `${SP} // unchanged here; in the IdP application:`;
35
+ const METADATA =
36
+ "parseSamlMetadata(await Bun.file('idp-metadata.xml').text()) // the IdP current metadata";
37
+
38
+ /**
39
+ * The repair per check. Each opens with the code to paste (`X_FIX_PROSE`): the SP side as a call,
40
+ * an IdP-side setting as that call's trailing comment, a retry as the route to start again at.
41
+ */
42
+ const FIXES: Readonly<Record<SamlCheck, string>> = {
43
+ encoding:
44
+ 'identityProviderRoutes(auth, { providers }).acs // mount as-is: SAMLResponse arrives base64 and is decoded once',
45
+ xml: `${IDP_APP} send a plain SAML 2.0 Response, no DOCTYPE or processing instruction`,
46
+ structure: `${IDP_APP} one samlp:Response holding exactly one saml:Assertion`,
47
+ 'id-duplicate':
48
+ 'oauthStartPath(id) // start again here; a response carrying one ID twice is a wrapping attempt, never accepted',
49
+ encrypted: `${IDP_APP} turn assertion encryption off (not supported)`,
50
+ 'signature-missing': `${IDP_APP} turn on "sign assertion" (a signed response alone is refused)`,
51
+ algorithm: `${IDP_APP} RSA-SHA256 signatures, SHA-256 digests, exclusive c14n`,
52
+ reference: `${IDP_APP} sign the assertion by its ID with the enveloped-signature transform`,
53
+ signature: `${METADATA}, pasted into samlIdentityProvider({ idp }): its signing certificate has likely rotated`,
54
+ digest:
55
+ 'oauthStartPath(id) // start again; if it repeats, remove the proxy rewriting the POST body between the IdP and this app',
56
+ status: `${IDP_APP} assign this user to the application (the IdP answer is in meta.status)`,
57
+ issuer: `${METADATA}: its entityID must be the assertion Issuer`,
58
+ destination: 'oauthCallbackPath(id) // the IdP application ACS URL is APP_URL + this path',
59
+ 'in-response-to':
60
+ 'oauthStartPath(id) // start the sign-in here, not from the IdP dashboard: IdP-initiated responses are refused',
61
+ recipient: 'oauthCallbackPath(id) // the IdP application ACS URL is APP_URL + this path',
62
+ audience:
63
+ 'samlIdentityProvider({ spEntityId }) // equal to the audience (SP entity id) the IdP application names',
64
+ 'not-before':
65
+ 'samlIdentityProvider({ skewMs: 120_000 }) // or sync the server clock first: chronyc makestep',
66
+ expired: 'oauthStartPath(id) // sync the server clock (chronyc makestep), then start again here',
67
+ subject: `${IDP_APP} NameID format persistent (or emailAddress)`,
68
+ 'relay-state': 'oauthStartPath(id) // start again here, in the same browser',
69
+ replay: 'oauthStartPath(id) // start again here: an assertion is accepted once',
70
+ };
71
+
72
+ /**
73
+ * Read as a `Map` (`proto-index`): the `Record` above is what makes a missing check a compile
74
+ * error; the lookup never indexes a plain object.
75
+ */
76
+ const FIX_BY_CHECK: ReadonlyMap<string, string> = new Map(Object.entries(FIXES));
77
+
78
+ export const samlResponseInvalid = (
79
+ provider: string,
80
+ check: SamlCheck,
81
+ detail: string,
82
+ meta: Readonly<Record<string, unknown>> = {},
83
+ ): AuthError =>
84
+ new AuthError({
85
+ code: 'X_SAML_RESPONSE_INVALID',
86
+ cause: `the ${renderCauseValue(provider)} SAML response failed its ${check} check: ${detail}`,
87
+ fix: FIX_BY_CHECK.get(check) ?? FIXES.xml,
88
+ meta: { ...meta, provider, check },
89
+ });
90
+
91
+ export const samlMetadataInvalid = (detail: string): AuthError =>
92
+ new AuthError({
93
+ code: 'X_SAML_METADATA_INVALID',
94
+ cause: `the SAML IdP metadata is unusable: ${detail}`,
95
+ fix: `${METADATA} XML, whole, downloaded again from its federation metadata URL`,
96
+ });
@@ -0,0 +1,80 @@
1
+ // Single responsibility: an IdP's SAML 2.0 metadata → its `entityID`, HTTP-Redirect sign-on URL
2
+ // and signing certificates. Parsed at boot, so a bad file fails the deploy, not the first login.
3
+ // The metadata is the operator's pinned trust anchor; its own signature is not checked here —
4
+ // fetch it over TLS, or paste it.
5
+
6
+ import { samlVerifyKey } from './saml-certificate';
7
+ import {
8
+ attribute,
9
+ BINDING_REDIRECT,
10
+ childElements,
11
+ DSIG_NS,
12
+ descendants,
13
+ isElement,
14
+ MD_NS,
15
+ textOf,
16
+ } from './saml-dom';
17
+ import { samlMetadataInvalid } from './saml-errors';
18
+ import { parseXml } from './saml-xml';
19
+
20
+ export interface SamlIdpMetadata {
21
+ readonly entityId: string;
22
+ /** The HTTP-Redirect SingleSignOnService location the AuthnRequest is sent to. */
23
+ readonly ssoUrl: string;
24
+ /** Base64 DER X.509 certificates. More than one during a key rollover: any of them verifies. */
25
+ readonly certificates: readonly string[];
26
+ }
27
+
28
+ function signingCertificates(descriptor: Parameters<typeof childElements>[0]): string[] {
29
+ const out: string[] = [];
30
+ for (const key of childElements(descriptor, MD_NS, 'KeyDescriptor')) {
31
+ const use = attribute(key, 'use');
32
+ if (use !== undefined && use !== 'signing') continue;
33
+ for (const node of descendants(key)) {
34
+ if (!isElement(node, DSIG_NS, 'X509Certificate')) continue;
35
+ const text = (textOf(node) ?? '').replace(/[ \t\n]+/g, '');
36
+ if (text !== '' && !out.includes(text)) out.push(text);
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Parse metadata, or throw `X_SAML_METADATA_INVALID` naming what is missing. */
43
+ export function parseSamlMetadata(xml: string): SamlIdpMetadata {
44
+ const parsed = parseXml(xml);
45
+ if (!parsed.ok) throw samlMetadataInvalid(`it is not readable XML (${parsed.reason})`);
46
+ const root = parsed.root;
47
+ if (!isElement(root, MD_NS, 'EntityDescriptor')) {
48
+ throw samlMetadataInvalid('the root is not one md:EntityDescriptor');
49
+ }
50
+ const entityId = attribute(root, 'entityID') ?? '';
51
+ if (entityId === '') throw samlMetadataInvalid('the EntityDescriptor has no entityID');
52
+ const descriptor = childElements(root, MD_NS, 'IDPSSODescriptor')[0];
53
+ if (descriptor === undefined) {
54
+ throw samlMetadataInvalid('there is no IDPSSODescriptor: this is not IdP metadata');
55
+ }
56
+ const sso = childElements(descriptor, MD_NS, 'SingleSignOnService').find(
57
+ (service) => attribute(service, 'Binding') === BINDING_REDIRECT,
58
+ );
59
+ const ssoUrl = sso === undefined ? '' : (attribute(sso, 'Location') ?? '');
60
+ if (!URL.canParse(ssoUrl)) {
61
+ throw samlMetadataInvalid('no HTTP-Redirect SingleSignOnService carries a Location URL');
62
+ }
63
+ const certificates = signingCertificates(descriptor);
64
+ if (certificates.length === 0) throw samlMetadataInvalid('no signing certificate is published');
65
+ return Object.freeze({ entityId, ssoUrl, certificates: Object.freeze(certificates) });
66
+ }
67
+
68
+ /** The verification keys, once per provider. A certificate that yields no RSA-2048+ key refuses. */
69
+ export async function samlMetadataKeys(idp: SamlIdpMetadata): Promise<readonly CryptoKey[]> {
70
+ const keys: CryptoKey[] = [];
71
+ for (const certificate of idp.certificates) {
72
+ const key = await samlVerifyKey(certificate);
73
+ if (key === undefined) {
74
+ throw samlMetadataInvalid('a signing certificate is unreadable, not RSA, or under 2048 bits');
75
+ }
76
+ keys.push(key);
77
+ }
78
+ if (keys.length === 0) throw samlMetadataInvalid('no signing certificate is published');
79
+ return keys;
80
+ }