@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,179 @@
1
+ // Single responsibility: SAML 2.0 Web SSO (SP-initiated, HTTP-Redirect out, HTTP-POST back) as an
2
+ // `IdentityProvider`. `begin` mints the AuthnRequest; `complete` binds the answer to that request
3
+ // (RelayState + InResponseTo), verifies it (`verifySamlResponse`), claims its assertion ID once,
4
+ // and reads one profile out of it.
5
+
6
+ import { timingSafeEqual } from '@ultimat3/core';
7
+ import {
8
+ type IdentityProvider,
9
+ type IdentityRedirect,
10
+ replayStepFor,
11
+ type VerifiedIdentity,
12
+ } from './identity-provider';
13
+ import { isRecord } from './json';
14
+ import { assertFiniteAuthCount } from './policy-numbers';
15
+ import type { SamlAssertion } from './saml-assertion';
16
+ import { NAMEID_EMAIL } from './saml-dom';
17
+ import { isTrustedAddress, type SamlEmailTrust, samlTrustedDomains } from './saml-email-trust';
18
+ import { samlResponseInvalid } from './saml-errors';
19
+ import { type SamlIdpMetadata, samlMetadataKeys } from './saml-metadata';
20
+ import { samlAuthnRequest } from './saml-request';
21
+ import { verifySamlResponse } from './saml-response';
22
+
23
+ /** The attribute names enterprise IdPs put an address under: Entra/ADFS, LDAP `mail`, eduPerson. */
24
+ export const DEFAULT_SAML_EMAIL_ATTRIBUTES: readonly string[] = Object.freeze([
25
+ 'email',
26
+ 'mail',
27
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
28
+ 'urn:oid:0.9.2342.19200300.100.1.3',
29
+ ]);
30
+ export const DEFAULT_SAML_NAME_ATTRIBUTES: readonly string[] = Object.freeze([
31
+ 'displayName',
32
+ 'name',
33
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
34
+ 'urn:oid:2.16.840.1.113730.3.1.241',
35
+ ]);
36
+ /** One minute either way: what the major IdPs document as tolerable clock drift. */
37
+ export const DEFAULT_SAML_SKEW_MS = 60_000;
38
+
39
+ interface SamlProviderBase {
40
+ readonly id: string;
41
+ /** `parseSamlMetadata(xml)` — the trust anchor. Its certificates are the only keys believed. */
42
+ readonly idp: SamlIdpMetadata;
43
+ /** This SP's entity id: the Audience every assertion must name. */
44
+ readonly spEntityId: string;
45
+ readonly emailAttributes?: readonly string[] | undefined;
46
+ readonly nameAttributes?: readonly string[] | undefined;
47
+ /** Requested in the AuthnRequest's NameIDPolicy. Default persistent; transient is refused. */
48
+ readonly nameIdFormat?: string | undefined;
49
+ readonly skewMs?: number | undefined;
50
+ }
51
+
52
+ /**
53
+ * `trustEmail` is REQUIRED, never defaulted: SAML carries no `email_verified`, so the app says
54
+ * whether this IdP vouches for its addresses — and `true` names the exact `emailDomains` it owns.
55
+ * An address outside them is unverified: it neither links to an existing account nor creates one.
56
+ */
57
+ export type SamlIdentityProviderConfig = SamlProviderBase & SamlEmailTrust;
58
+
59
+ interface SamlState {
60
+ readonly requestId: string;
61
+ readonly relayState: string;
62
+ }
63
+
64
+ function readState(provider: string, state: string): SamlState {
65
+ let parsed: unknown;
66
+ try {
67
+ parsed = JSON.parse(state);
68
+ } catch {
69
+ parsed = null;
70
+ }
71
+ if (
72
+ !isRecord(parsed) ||
73
+ typeof parsed['requestId'] !== 'string' ||
74
+ typeof parsed['relayState'] !== 'string'
75
+ ) {
76
+ throw samlResponseInvalid(provider, 'relay-state', 'the stored request is not a SAML request');
77
+ }
78
+ return { requestId: parsed['requestId'], relayState: parsed['relayState'] };
79
+ }
80
+
81
+ const firstOf = (assertion: SamlAssertion, names: readonly string[]): string | null => {
82
+ for (const name of names) {
83
+ const value = assertion.attributes[name]?.find((one) => one.trim() !== '');
84
+ if (value !== undefined) return value.trim();
85
+ }
86
+ return null;
87
+ };
88
+
89
+ function identityOf(
90
+ config: SamlIdentityProviderConfig,
91
+ trusted: ReadonlySet<string>,
92
+ assertion: SamlAssertion,
93
+ ): VerifiedIdentity {
94
+ const email =
95
+ firstOf(assertion, config.emailAttributes ?? DEFAULT_SAML_EMAIL_ATTRIBUTES) ??
96
+ (assertion.nameIdFormat === NAMEID_EMAIL ? assertion.nameId : null);
97
+ return {
98
+ profile: {
99
+ provider: config.id,
100
+ providerAccountId: assertion.nameId,
101
+ email,
102
+ emailVerified: email !== null && isTrustedAddress(email, trusted),
103
+ name: firstOf(assertion, config.nameAttributes ?? DEFAULT_SAML_NAME_ATTRIBUTES),
104
+ },
105
+ expiresAt: assertion.sessionNotOnOrAfter,
106
+ claims: {
107
+ issuer: assertion.issuer,
108
+ nameIdFormat: assertion.nameIdFormat,
109
+ sessionIndex: assertion.sessionIndex,
110
+ attributes: assertion.attributes,
111
+ },
112
+ };
113
+ }
114
+
115
+ export function samlIdentityProvider(config: SamlIdentityProviderConfig): IdentityProvider {
116
+ const skewMs = assertFiniteAuthCount(
117
+ 'saml.skewMs',
118
+ config.skewMs ?? DEFAULT_SAML_SKEW_MS,
119
+ 'every NotBefore and NotOnOrAfter comparison answers false, so an expired assertion is accepted',
120
+ 0,
121
+ );
122
+ // Imported once, lazily, then shared: a certificate that yields no key refuses the first login
123
+ // with X_SAML_METADATA_INVALID rather than every login quietly failing its signature check.
124
+ let keys: Promise<readonly CryptoKey[]> | undefined;
125
+ const id = config.id;
126
+ const trusted = samlTrustedDomains(id, config);
127
+ const provider: IdentityProvider = {
128
+ id,
129
+ protocol: 'saml',
130
+ callbackMethod: 'POST',
131
+ async begin({ callbackUrl, clock }): Promise<IdentityRedirect> {
132
+ const request = samlAuthnRequest({
133
+ idp: config.idp,
134
+ spEntityId: config.spEntityId,
135
+ acsUrl: callbackUrl,
136
+ clock,
137
+ nameIdFormat: config.nameIdFormat,
138
+ });
139
+ const state: SamlState = { requestId: request.id, relayState: request.relayState };
140
+ return { location: request.location, state: JSON.stringify(state) };
141
+ },
142
+ answers(state, params) {
143
+ try {
144
+ return timingSafeEqual(readState(id, state).relayState, params['RelayState'] ?? '');
145
+ } catch {
146
+ return false;
147
+ }
148
+ },
149
+ async complete({ state, params, callbackUrl, clock, replay }): Promise<VerifiedIdentity> {
150
+ const stored = readState(id, state);
151
+ if (!timingSafeEqual(stored.relayState, params['RelayState'] ?? '')) {
152
+ throw samlResponseInvalid(
153
+ id,
154
+ 'relay-state',
155
+ 'RelayState is not the one this browser was sent with',
156
+ );
157
+ }
158
+ keys ??= samlMetadataKeys(config.idp);
159
+ const now = clock.now();
160
+ const assertion = await verifySamlResponse(params['SAMLResponse'] ?? '', {
161
+ provider: id,
162
+ idpEntityId: config.idp.entityId,
163
+ keys: await keys,
164
+ audience: config.spEntityId,
165
+ recipient: callbackUrl,
166
+ requestId: stored.requestId,
167
+ now,
168
+ skewMs,
169
+ });
170
+ const horizon = new Date(assertion.notOnOrAfter.getTime() + skewMs);
171
+ const subject = `saml:${config.idp.entityId}:${assertion.id}`;
172
+ if (!(await replay.claim(subject, replayStepFor(horizon), now))) {
173
+ throw samlResponseInvalid(id, 'replay', 'this assertion was already consumed');
174
+ }
175
+ return identityOf(config, trusted, assertion);
176
+ },
177
+ };
178
+ return Object.freeze(provider);
179
+ }
@@ -0,0 +1,51 @@
1
+ // Single responsibility: the SP-initiated leg — an AuthnRequest, DEFLATEd and base64'd onto the
2
+ // IdP's HTTP-Redirect endpoint with a RelayState (SAML Bindings §3.4). Unsigned: the response is
3
+ // what is verified, and the request's ID comes back as InResponseTo, which is the binding that
4
+ // matters. The ID and RelayState are random and returned so the caller can seal them.
5
+
6
+ import { type Clock, escapeHtml } from '@ultimat3/core';
7
+ import { BINDING_POST, NAMEID_PERSISTENT, SAML_NS, SAMLP_NS } from './saml-dom';
8
+ import type { SamlIdpMetadata } from './saml-metadata';
9
+ import { randomToken } from './tokens';
10
+
11
+ export interface SamlAuthnRequestInput {
12
+ readonly idp: SamlIdpMetadata;
13
+ readonly spEntityId: string;
14
+ /** Where the IdP POSTs the response: the callback route for this provider. */
15
+ readonly acsUrl: string;
16
+ readonly clock: Clock;
17
+ readonly nameIdFormat?: string | undefined;
18
+ }
19
+
20
+ export interface SamlAuthnRequest {
21
+ /** `ID` of the request; the response must answer it as `InResponseTo`. */
22
+ readonly id: string;
23
+ /** Echoed by the IdP; ≤ 80 bytes as Bindings §3.4.3 requires. */
24
+ readonly relayState: string;
25
+ readonly xml: string;
26
+ /** The IdP URL to redirect the browser to. */
27
+ readonly location: string;
28
+ }
29
+
30
+ /** IssueInstant is UTC by definition (SAML Core §1.3.3), without milliseconds. */
31
+ const instant = (at: Date): string => `${at.toISOString().slice(0, 19)}Z`;
32
+
33
+ export function samlAuthnRequest(input: SamlAuthnRequestInput): SamlAuthnRequest {
34
+ // `_` first: an xs:ID is an NCName, which may not start with a digit; base64url is NCName-safe.
35
+ const id = `_${randomToken(20)}`;
36
+ const relayState = randomToken(24);
37
+ const xml =
38
+ `<samlp:AuthnRequest xmlns:samlp="${SAMLP_NS}" xmlns:saml="${SAML_NS}"` +
39
+ ` ID="${id}" Version="2.0" IssueInstant="${instant(input.clock.now())}"` +
40
+ ` Destination="${escapeHtml(input.idp.ssoUrl)}"` +
41
+ ` AssertionConsumerServiceURL="${escapeHtml(input.acsUrl)}"` +
42
+ ` ProtocolBinding="${BINDING_POST}">` +
43
+ `<saml:Issuer>${escapeHtml(input.spEntityId)}</saml:Issuer>` +
44
+ `<samlp:NameIDPolicy Format="${escapeHtml(input.nameIdFormat ?? NAMEID_PERSISTENT)}" AllowCreate="true"/>` +
45
+ '</samlp:AuthnRequest>';
46
+ const deflated = Bun.deflateSync(new TextEncoder().encode(xml));
47
+ const url = new URL(input.idp.ssoUrl);
48
+ url.searchParams.set('SAMLRequest', btoa(String.fromCharCode(...deflated)));
49
+ url.searchParams.set('RelayState', relayState);
50
+ return { id, relayState, xml, location: url.toString() };
51
+ }
@@ -0,0 +1,131 @@
1
+ // Single responsibility: a posted SAMLResponse → one verified `SamlAssertion`, or
2
+ // `X_SAML_RESPONSE_INVALID` naming the check. The shape is pinned before any signature — one
3
+ // Response, ONE Assertion in the document as its direct child, no ID twice, nothing encrypted —
4
+ // and the assertion's OWN signature is required; a response signature, if present, must verify.
5
+
6
+ import {
7
+ type AssertionExpectations,
8
+ AssertionRefusal,
9
+ readAssertion,
10
+ type SamlAssertion,
11
+ } from './saml-assertion';
12
+ import { base64Bytes } from './saml-certificate';
13
+ import {
14
+ attribute,
15
+ childElements,
16
+ descendants,
17
+ isElement,
18
+ SAML_NS,
19
+ SAMLP_NS,
20
+ STATUS_SUCCESS,
21
+ textOf,
22
+ } from './saml-dom';
23
+ import { verifyEnvelopedSignature } from './saml-dsig';
24
+ import { samlResponseInvalid } from './saml-errors';
25
+ import { parseXml, type XmlElement } from './saml-xml';
26
+
27
+ export interface SamlResponseExpectations extends Omit<AssertionExpectations, 'issuer'> {
28
+ /** The provider id, for the refusal. */
29
+ readonly provider: string;
30
+ readonly idpEntityId: string;
31
+ readonly keys: readonly CryptoKey[];
32
+ }
33
+
34
+ function decodeResponse(provider: string, posted: string): XmlElement {
35
+ const bytes = base64Bytes(posted);
36
+ if (bytes === undefined)
37
+ throw samlResponseInvalid(provider, 'encoding', 'SAMLResponse is not base64');
38
+ let xml: string;
39
+ try {
40
+ xml = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
41
+ } catch {
42
+ throw samlResponseInvalid(provider, 'encoding', 'SAMLResponse is not UTF-8');
43
+ }
44
+ const parsed = parseXml(xml);
45
+ if (!parsed.ok)
46
+ throw samlResponseInvalid(provider, 'xml', 'the document was refused by the reader');
47
+ return parsed.root;
48
+ }
49
+
50
+ /** The one assertion, after every structural refusal a wrapping attack would need to survive. */
51
+ function soleAssertion(provider: string, response: XmlElement): XmlElement {
52
+ if (!isElement(response, SAMLP_NS, 'Response')) {
53
+ throw samlResponseInvalid(provider, 'structure', 'the root is not samlp:Response');
54
+ }
55
+ const all = descendants(response);
56
+ const ids = new Set<string>();
57
+ for (const element of [response, ...all]) {
58
+ const id = attribute(element, 'ID');
59
+ if (id === undefined) continue;
60
+ if (ids.has(id))
61
+ throw samlResponseInvalid(provider, 'id-duplicate', 'two elements share one ID');
62
+ ids.add(id);
63
+ }
64
+ if (all.some((el) => el.local === 'EncryptedAssertion' || el.local === 'EncryptedID')) {
65
+ throw samlResponseInvalid(provider, 'encrypted', 'encrypted assertions are not supported');
66
+ }
67
+ const assertions = all.filter((el) => isElement(el, SAML_NS, 'Assertion'));
68
+ const assertion = assertions[0];
69
+ if (assertions.length !== 1 || assertion === undefined || assertion.parent !== response) {
70
+ throw samlResponseInvalid(
71
+ provider,
72
+ 'structure',
73
+ 'the response must hold exactly one assertion, directly',
74
+ );
75
+ }
76
+ return assertion;
77
+ }
78
+
79
+ function responseHeader(
80
+ provider: string,
81
+ response: XmlElement,
82
+ expect: SamlResponseExpectations,
83
+ ): void {
84
+ const status = childElements(response, SAMLP_NS, 'Status')[0];
85
+ const code = status === undefined ? undefined : childElements(status, SAMLP_NS, 'StatusCode')[0];
86
+ const value = code === undefined ? undefined : attribute(code, 'Value');
87
+ if (value !== STATUS_SUCCESS) {
88
+ throw samlResponseInvalid(provider, 'status', 'the IdP did not answer Success', {
89
+ status: value ?? null,
90
+ });
91
+ }
92
+ const destination = attribute(response, 'Destination');
93
+ if (destination !== undefined && destination !== expect.recipient) {
94
+ throw samlResponseInvalid(provider, 'destination', 'the Destination is not this ACS URL');
95
+ }
96
+ const inResponseTo = attribute(response, 'InResponseTo');
97
+ if (inResponseTo !== expect.requestId) {
98
+ throw samlResponseInvalid(provider, 'in-response-to', 'the response answers another request');
99
+ }
100
+ const issuer = childElements(response, SAML_NS, 'Issuer')[0];
101
+ if (issuer !== undefined && textOf(issuer) !== expect.idpEntityId) {
102
+ throw samlResponseInvalid(
103
+ provider,
104
+ 'issuer',
105
+ 'the response Issuer is not the metadata entityID',
106
+ );
107
+ }
108
+ }
109
+
110
+ export async function verifySamlResponse(
111
+ posted: string,
112
+ expect: SamlResponseExpectations,
113
+ ): Promise<SamlAssertion> {
114
+ const { provider, keys } = expect;
115
+ const response = decodeResponse(provider, posted);
116
+ const assertion = soleAssertion(provider, response);
117
+ const outer = await verifyEnvelopedSignature(response, keys);
118
+ if (!outer.ok && outer.check !== 'signature-missing') {
119
+ throw samlResponseInvalid(provider, outer.check, `response: ${outer.detail}`);
120
+ }
121
+ const inner = await verifyEnvelopedSignature(assertion, keys);
122
+ if (!inner.ok) throw samlResponseInvalid(provider, inner.check, `assertion: ${inner.detail}`);
123
+ responseHeader(provider, response, expect);
124
+ try {
125
+ return readAssertion(assertion, { ...expect, issuer: expect.idpEntityId });
126
+ } catch (error) {
127
+ if (error instanceof AssertionRefusal)
128
+ throw samlResponseInvalid(provider, error.check, error.detail);
129
+ throw error;
130
+ }
131
+ }
@@ -0,0 +1,328 @@
1
+ // Single responsibility: a strict, namespace-aware XML reader for the SAML subset — a tree that
2
+ // keeps exactly what exclusive c14n and XML-DSig need (prefixes, in-scope namespaces, comments as
3
+ // nodes) and REFUSES what SAML never needs and attackers always do: a DOCTYPE (entity expansion,
4
+ // XXE), a processing instruction, an unknown entity, an undeclared prefix, a duplicate attribute.
5
+
6
+ /** The namespace `xml:` is bound to by definition (Namespaces in XML 1.0 §3). */
7
+ export const XML_NS = 'http://www.w3.org/XML/1998/namespace';
8
+
9
+ export interface XmlAttribute {
10
+ readonly qname: string;
11
+ readonly prefix: string | null;
12
+ readonly local: string;
13
+ /** `''` for an unprefixed attribute: it is in no namespace. */
14
+ readonly ns: string;
15
+ readonly value: string;
16
+ }
17
+
18
+ export interface XmlText {
19
+ readonly kind: 'text';
20
+ readonly value: string;
21
+ }
22
+
23
+ export interface XmlComment {
24
+ readonly kind: 'comment';
25
+ readonly value: string;
26
+ }
27
+
28
+ export interface XmlElement {
29
+ readonly kind: 'element';
30
+ readonly qname: string;
31
+ readonly prefix: string | null;
32
+ readonly local: string;
33
+ readonly ns: string;
34
+ readonly attributes: readonly XmlAttribute[];
35
+ /** Every prefix (`''` for the default) in scope here, ancestors' declarations included. */
36
+ readonly scope: NamespaceScope;
37
+ readonly children: readonly XmlNode[];
38
+ readonly parent: XmlElement | null;
39
+ }
40
+
41
+ export type XmlNode = XmlElement | XmlText | XmlComment;
42
+
43
+ /** A prefix lookup through the element's own declarations, then its ancestors'. */
44
+ export interface NamespaceScope {
45
+ get(prefix: string): string | undefined;
46
+ has(prefix: string): boolean;
47
+ }
48
+
49
+ /**
50
+ * Each element keeps only ITS declarations and asks its parent for the rest. A copied map per
51
+ * declaring element was K declarations × N elements of memory — 5 GB from one 334 KB POST.
52
+ * Depth is capped at `MAX_DEPTH`, so a lookup walks at most that many links.
53
+ */
54
+ class ScopeChain implements NamespaceScope {
55
+ constructor(
56
+ private readonly own: ReadonlyMap<string, string>,
57
+ private readonly parent: NamespaceScope | null,
58
+ ) {}
59
+ get(prefix: string): string | undefined {
60
+ return this.own.get(prefix) ?? this.parent?.get(prefix);
61
+ }
62
+ has(prefix: string): boolean {
63
+ return this.get(prefix) !== undefined;
64
+ }
65
+ }
66
+
67
+ const NO_SCOPE: NamespaceScope = { get: () => undefined, has: () => false };
68
+
69
+ export type XmlParse =
70
+ | { readonly ok: true; readonly root: XmlElement }
71
+ | { readonly ok: false; readonly reason: string };
72
+
73
+ /** A SAML response is a few KiB; an IdP metadata file with many certificates, tens. */
74
+ export const MAX_XML_LENGTH = 512 * 1024;
75
+ const MAX_DEPTH = 64;
76
+ /** Namespace declarations per document. SAML uses a handful; every one is lookup work per element. */
77
+ export const MAX_NAMESPACE_DECLARATIONS = 64;
78
+
79
+ const NAME = /^[A-Za-z_][A-Za-z0-9._-]*(?::[A-Za-z_][A-Za-z0-9._-]*)?/;
80
+ /** A `Map`, so an entity name from the document never reaches `Object.prototype` (`proto-index`). */
81
+ const PREDEFINED: ReadonlyMap<string, string> = new Map([
82
+ ['lt', '<'],
83
+ ['gt', '>'],
84
+ ['amp', '&'],
85
+ ['quot', '"'],
86
+ ['apos', "'"],
87
+ ]);
88
+ /** XML 1.0 `Char` minus the line breaks and tab, which are handled before this is asked. */
89
+ const isXmlChar = (code: number): boolean =>
90
+ code === 0x9 ||
91
+ code === 0xa ||
92
+ code === 0xd ||
93
+ (code >= 0x20 && code <= 0xd7ff) ||
94
+ (code >= 0xe000 && code <= 0xfffd) ||
95
+ (code >= 0x10000 && code <= 0x10ffff);
96
+
97
+ class Refusal {
98
+ constructor(readonly reason: string) {}
99
+ }
100
+ const refuse = (reason: string): never => {
101
+ throw new Refusal(reason);
102
+ };
103
+
104
+ /** Entity and character references, decoded; anything else after `&` is refused. */
105
+ function decode(raw: string): string {
106
+ if (!raw.includes('&')) return raw;
107
+ return raw.replace(/&([^;&]*);|&/g, (whole, body: string | undefined) => {
108
+ if (body === undefined) return refuse('a bare & is not a reference');
109
+ const named = PREDEFINED.get(body);
110
+ if (named !== undefined) return named;
111
+ const match = /^#(?:x([0-9A-Fa-f]{1,6})|([0-9]{1,7}))$/.exec(body);
112
+ if (match === null)
113
+ return refuse(`${whole.length > 12 ? 'an' : whole} entity is not one of the five predefined`);
114
+ const code = match[1] === undefined ? Number(match[2]) : Number.parseInt(match[1], 16);
115
+ if (!isXmlChar(code)) return refuse('a character reference names no XML character');
116
+ return String.fromCodePoint(code);
117
+ });
118
+ }
119
+
120
+ function assertChars(text: string): void {
121
+ for (const char of text) {
122
+ if (!isXmlChar(char.codePointAt(0) ?? 0)) refuse('the document carries a non-XML character');
123
+ }
124
+ }
125
+
126
+ interface RawAttribute {
127
+ readonly qname: string;
128
+ readonly value: string;
129
+ }
130
+
131
+ class Reader {
132
+ at = 0;
133
+ /** Namespace declarations read so far, across the whole document. */
134
+ declarations = 0;
135
+ constructor(readonly text: string) {}
136
+
137
+ startsWith(marker: string): boolean {
138
+ return this.text.startsWith(marker, this.at);
139
+ }
140
+
141
+ skipSpace(): void {
142
+ while (/[ \t\n]/.test(this.text[this.at] ?? '')) this.at += 1;
143
+ }
144
+
145
+ name(): string {
146
+ const match = NAME.exec(this.text.slice(this.at, this.at + 256));
147
+ if (match === null) return refuse('a name was expected');
148
+ this.at += match[0].length;
149
+ return match[0];
150
+ }
151
+
152
+ until(marker: string, what: string): string {
153
+ const end = this.text.indexOf(marker, this.at);
154
+ if (end < 0) return refuse(`an unterminated ${what}`);
155
+ const body = this.text.slice(this.at, end);
156
+ this.at = end + marker.length;
157
+ return body;
158
+ }
159
+ }
160
+
161
+ function split(qname: string): { prefix: string | null; local: string } {
162
+ const colon = qname.indexOf(':');
163
+ return colon < 0
164
+ ? { prefix: null, local: qname }
165
+ : { prefix: qname.slice(0, colon), local: qname.slice(colon + 1) };
166
+ }
167
+
168
+ function readAttributes(reader: Reader): RawAttribute[] {
169
+ const out: RawAttribute[] = [];
170
+ const names = new Set<string>();
171
+ for (;;) {
172
+ const before = reader.at;
173
+ reader.skipSpace();
174
+ if (reader.startsWith('>') || reader.startsWith('/>')) return out;
175
+ if (reader.at === before) refuse('attributes must be separated by white space');
176
+ const qname = reader.name();
177
+ // By qname, before any namespace resolves: `xmlns:p` twice would otherwise be last-one-wins.
178
+ if (names.has(qname)) refuse('an element carries one attribute twice');
179
+ names.add(qname);
180
+ reader.skipSpace();
181
+ if (!reader.startsWith('=')) refuse('an attribute has no value');
182
+ reader.at += 1;
183
+ reader.skipSpace();
184
+ const quote = reader.text[reader.at];
185
+ if (quote !== '"' && quote !== "'") return refuse('an attribute value is not quoted');
186
+ reader.at += 1;
187
+ const raw = reader.until(quote, 'attribute value');
188
+ if (raw.includes('<')) refuse('an attribute value carries <');
189
+ // Attribute-value normalisation (XML 1.0 §3.3.3): a literal tab or line break is a space;
190
+ // one written as a character reference is not, which is why this runs before `decode`.
191
+ out.push({ qname, value: decode(raw.replace(/[\t\n]/g, ' ')) });
192
+ }
193
+ }
194
+
195
+ function scopeFor(reader: Reader, raw: readonly RawAttribute[], inherited: NamespaceScope) {
196
+ let own: Map<string, string> | null = null;
197
+ for (const { qname, value } of raw) {
198
+ if (qname !== 'xmlns' && !qname.startsWith('xmlns:')) continue;
199
+ const prefix = qname === 'xmlns' ? '' : qname.slice(6);
200
+ if (prefix === 'xml' || prefix === 'xmlns') refuse('the xml and xmlns prefixes are reserved');
201
+ if (prefix !== '' && value === '') refuse('a prefix may not be undeclared');
202
+ reader.declarations += 1;
203
+ if (reader.declarations > MAX_NAMESPACE_DECLARATIONS) {
204
+ refuse(`the document carries more than ${MAX_NAMESPACE_DECLARATIONS} namespace declarations`);
205
+ }
206
+ own ??= new Map();
207
+ own.set(prefix, value);
208
+ }
209
+ // An element that declares nothing shares its parent's scope object: no allocation at all.
210
+ return own === null ? inherited : new ScopeChain(own, inherited);
211
+ }
212
+
213
+ function resolve(prefix: string | null, scope: NamespaceScope, attr: boolean): string {
214
+ if (prefix === null) return attr ? '' : (scope.get('') ?? '');
215
+ if (prefix === 'xml') return XML_NS;
216
+ const ns = scope.get(prefix);
217
+ return ns === undefined ? refuse('a prefix is used without a declaration') : ns;
218
+ }
219
+
220
+ function element(reader: Reader, parent: XmlElement | null, depth: number): XmlElement {
221
+ if (depth > MAX_DEPTH) refuse('the document nests deeper than any SAML message');
222
+ reader.at += 1; // '<'
223
+ const qname = reader.name();
224
+ const raw = readAttributes(reader);
225
+ const scope = scopeFor(reader, raw, parent?.scope ?? NO_SCOPE);
226
+ const attributes: XmlAttribute[] = [];
227
+ const seen = new Set<string>();
228
+ for (const { qname: name, value } of raw) {
229
+ if (name === 'xmlns' || name.startsWith('xmlns:')) continue;
230
+ const { prefix, local } = split(name);
231
+ const ns = resolve(prefix, scope, true);
232
+ if (seen.has(`${ns} ${local}`)) refuse('an element carries one attribute twice');
233
+ seen.add(`${ns} ${local}`);
234
+ attributes.push({ qname: name, prefix, local, ns, value });
235
+ }
236
+ const { prefix, local } = split(qname);
237
+ const children: XmlNode[] = [];
238
+ const node: XmlElement = {
239
+ kind: 'element',
240
+ qname,
241
+ prefix,
242
+ local,
243
+ ns: resolve(prefix, scope, false),
244
+ attributes,
245
+ scope,
246
+ children,
247
+ parent,
248
+ };
249
+ if (reader.startsWith('/>')) {
250
+ reader.at += 2;
251
+ return node;
252
+ }
253
+ reader.at += 1; // '>'
254
+ content(reader, node, children, depth);
255
+ return node;
256
+ }
257
+
258
+ function pushText(children: XmlNode[], value: string): void {
259
+ if (value === '') return;
260
+ const last = children.at(-1);
261
+ if (last?.kind === 'text')
262
+ children[children.length - 1] = { kind: 'text', value: last.value + value };
263
+ else children.push({ kind: 'text', value });
264
+ }
265
+
266
+ function content(reader: Reader, node: XmlElement, children: XmlNode[], depth: number): void {
267
+ for (;;) {
268
+ const lt = reader.text.indexOf('<', reader.at);
269
+ if (lt < 0) refuse('an element is never closed');
270
+ const text = reader.text.slice(reader.at, lt);
271
+ if (text.includes(']]>')) refuse('text carries ]]>');
272
+ pushText(children, decode(text));
273
+ reader.at = lt;
274
+ if (reader.startsWith('</')) {
275
+ reader.at += 2;
276
+ if (reader.name() !== node.qname) refuse('an end tag does not match its start tag');
277
+ reader.skipSpace();
278
+ if (!reader.startsWith('>')) refuse('an end tag is not closed');
279
+ reader.at += 1;
280
+ return;
281
+ }
282
+ if (reader.startsWith('<!--')) {
283
+ reader.at += 4;
284
+ const body = reader.until('-->', 'comment');
285
+ if (body.includes('--') || body.endsWith('-')) refuse('a comment carries --');
286
+ children.push({ kind: 'comment', value: body });
287
+ } else if (reader.startsWith('<![CDATA[')) {
288
+ reader.at += 9;
289
+ pushText(children, reader.until(']]>', 'CDATA section'));
290
+ } else if (reader.startsWith('<!') || reader.startsWith('<?')) {
291
+ refuse('a DOCTYPE, entity declaration or processing instruction is refused');
292
+ } else {
293
+ children.push(element(reader, node, depth + 1));
294
+ }
295
+ }
296
+ }
297
+
298
+ /** Comments and white space are all that may stand outside the root. */
299
+ function misc(reader: Reader): void {
300
+ for (;;) {
301
+ reader.skipSpace();
302
+ if (!reader.startsWith('<!--')) return;
303
+ reader.at += 4;
304
+ reader.until('-->', 'comment');
305
+ }
306
+ }
307
+
308
+ export function parseXml(input: string): XmlParse {
309
+ if (input.length > MAX_XML_LENGTH) return { ok: false, reason: 'the document is too large' };
310
+ try {
311
+ // Line-end normalisation (XML 1.0 §2.11) before anything else reads a character.
312
+ const text = input.replace(/^/, '').replace(/\r\n?/g, '\n');
313
+ assertChars(text);
314
+ const reader = new Reader(text);
315
+ if (/^<\?xml[ \t\n]/.test(text)) reader.until('?>', 'XML declaration');
316
+ misc(reader);
317
+ if (!reader.startsWith('<') || reader.startsWith('<!') || reader.startsWith('<?')) {
318
+ refuse('the document has no root element, or a DOCTYPE or processing instruction before it');
319
+ }
320
+ const root = element(reader, null, 0);
321
+ misc(reader);
322
+ if (reader.at !== text.length) refuse('content follows the root element');
323
+ return { ok: true, root };
324
+ } catch (error) {
325
+ if (error instanceof Refusal) return { ok: false, reason: error.reason };
326
+ throw error;
327
+ }
328
+ }