@c9up/transit 0.1.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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/dist/Oauth1Driver.d.ts +68 -0
  4. package/dist/Oauth1Driver.d.ts.map +1 -0
  5. package/dist/Oauth1Driver.js +148 -0
  6. package/dist/Oauth1Driver.js.map +1 -0
  7. package/dist/Oauth2Driver.d.ts +89 -0
  8. package/dist/Oauth2Driver.d.ts.map +1 -0
  9. package/dist/Oauth2Driver.js +196 -0
  10. package/dist/Oauth2Driver.js.map +1 -0
  11. package/dist/TransitManager.d.ts +59 -0
  12. package/dist/TransitManager.d.ts.map +1 -0
  13. package/dist/TransitManager.js +100 -0
  14. package/dist/TransitManager.js.map +1 -0
  15. package/dist/TransitProvider.d.ts +26 -0
  16. package/dist/TransitProvider.d.ts.map +1 -0
  17. package/dist/TransitProvider.js +32 -0
  18. package/dist/TransitProvider.js.map +1 -0
  19. package/dist/apple.d.ts +45 -0
  20. package/dist/apple.d.ts.map +1 -0
  21. package/dist/apple.js +80 -0
  22. package/dist/apple.js.map +1 -0
  23. package/dist/ber.d.ts +50 -0
  24. package/dist/ber.d.ts.map +1 -0
  25. package/dist/ber.js +156 -0
  26. package/dist/ber.js.map +1 -0
  27. package/dist/c14n.d.ts +38 -0
  28. package/dist/c14n.d.ts.map +1 -0
  29. package/dist/c14n.js +129 -0
  30. package/dist/c14n.js.map +1 -0
  31. package/dist/config.d.ts +115 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +146 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/configure.d.ts +18 -0
  36. package/dist/configure.d.ts.map +1 -0
  37. package/dist/configure.js +33 -0
  38. package/dist/configure.js.map +1 -0
  39. package/dist/drivers/AppleDriver.d.ts +36 -0
  40. package/dist/drivers/AppleDriver.d.ts.map +1 -0
  41. package/dist/drivers/AppleDriver.js +58 -0
  42. package/dist/drivers/AppleDriver.js.map +1 -0
  43. package/dist/drivers/DiscordDriver.d.ts +15 -0
  44. package/dist/drivers/DiscordDriver.d.ts.map +1 -0
  45. package/dist/drivers/DiscordDriver.js +45 -0
  46. package/dist/drivers/DiscordDriver.js.map +1 -0
  47. package/dist/drivers/FacebookDriver.d.ts +15 -0
  48. package/dist/drivers/FacebookDriver.d.ts.map +1 -0
  49. package/dist/drivers/FacebookDriver.js +47 -0
  50. package/dist/drivers/FacebookDriver.js.map +1 -0
  51. package/dist/drivers/GitHubDriver.d.ts +33 -0
  52. package/dist/drivers/GitHubDriver.d.ts.map +1 -0
  53. package/dist/drivers/GitHubDriver.js +72 -0
  54. package/dist/drivers/GitHubDriver.js.map +1 -0
  55. package/dist/drivers/GoogleDriver.d.ts +16 -0
  56. package/dist/drivers/GoogleDriver.d.ts.map +1 -0
  57. package/dist/drivers/GoogleDriver.js +31 -0
  58. package/dist/drivers/GoogleDriver.js.map +1 -0
  59. package/dist/drivers/LdapDirectory.d.ts +51 -0
  60. package/dist/drivers/LdapDirectory.d.ts.map +1 -0
  61. package/dist/drivers/LdapDirectory.js +127 -0
  62. package/dist/drivers/LdapDirectory.js.map +1 -0
  63. package/dist/drivers/LinkedInDriver.d.ts +29 -0
  64. package/dist/drivers/LinkedInDriver.d.ts.map +1 -0
  65. package/dist/drivers/LinkedInDriver.js +97 -0
  66. package/dist/drivers/LinkedInDriver.js.map +1 -0
  67. package/dist/drivers/LinkedInOpenidConnectDriver.d.ts +17 -0
  68. package/dist/drivers/LinkedInOpenidConnectDriver.d.ts.map +1 -0
  69. package/dist/drivers/LinkedInOpenidConnectDriver.js +27 -0
  70. package/dist/drivers/LinkedInOpenidConnectDriver.js.map +1 -0
  71. package/dist/drivers/OidcDriver.d.ts +64 -0
  72. package/dist/drivers/OidcDriver.d.ts.map +1 -0
  73. package/dist/drivers/OidcDriver.js +259 -0
  74. package/dist/drivers/OidcDriver.js.map +1 -0
  75. package/dist/drivers/SamlDriver.d.ts +66 -0
  76. package/dist/drivers/SamlDriver.d.ts.map +1 -0
  77. package/dist/drivers/SamlDriver.js +228 -0
  78. package/dist/drivers/SamlDriver.js.map +1 -0
  79. package/dist/drivers/SpotifyDriver.d.ts +16 -0
  80. package/dist/drivers/SpotifyDriver.d.ts.map +1 -0
  81. package/dist/drivers/SpotifyDriver.js +34 -0
  82. package/dist/drivers/SpotifyDriver.js.map +1 -0
  83. package/dist/drivers/TwitterDriver.d.ts +21 -0
  84. package/dist/drivers/TwitterDriver.d.ts.map +1 -0
  85. package/dist/drivers/TwitterDriver.js +46 -0
  86. package/dist/drivers/TwitterDriver.js.map +1 -0
  87. package/dist/drivers/TwitterXDriver.d.ts +22 -0
  88. package/dist/drivers/TwitterXDriver.d.ts.map +1 -0
  89. package/dist/drivers/TwitterXDriver.js +47 -0
  90. package/dist/drivers/TwitterXDriver.js.map +1 -0
  91. package/dist/dsig.d.ts +51 -0
  92. package/dist/dsig.d.ts.map +1 -0
  93. package/dist/dsig.js +273 -0
  94. package/dist/dsig.js.map +1 -0
  95. package/dist/index.d.ts +38 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +33 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/jwt.d.ts +135 -0
  100. package/dist/jwt.d.ts.map +1 -0
  101. package/dist/jwt.js +167 -0
  102. package/dist/jwt.js.map +1 -0
  103. package/dist/ldap.d.ts +82 -0
  104. package/dist/ldap.d.ts.map +1 -0
  105. package/dist/ldap.js +271 -0
  106. package/dist/ldap.js.map +1 -0
  107. package/dist/nodeEnv.d.ts +17 -0
  108. package/dist/nodeEnv.d.ts.map +1 -0
  109. package/dist/nodeEnv.js +33 -0
  110. package/dist/nodeEnv.js.map +1 -0
  111. package/dist/oauth1.d.ts +68 -0
  112. package/dist/oauth1.d.ts.map +1 -0
  113. package/dist/oauth1.js +106 -0
  114. package/dist/oauth1.js.map +1 -0
  115. package/dist/oidc.d.ts +61 -0
  116. package/dist/oidc.d.ts.map +1 -0
  117. package/dist/oidc.js +151 -0
  118. package/dist/oidc.js.map +1 -0
  119. package/dist/quasar.d.ts +14 -0
  120. package/dist/quasar.d.ts.map +1 -0
  121. package/dist/quasar.js +42 -0
  122. package/dist/quasar.js.map +1 -0
  123. package/dist/replay.d.ts +70 -0
  124. package/dist/replay.d.ts.map +1 -0
  125. package/dist/replay.js +82 -0
  126. package/dist/replay.js.map +1 -0
  127. package/dist/saml.d.ts +68 -0
  128. package/dist/saml.d.ts.map +1 -0
  129. package/dist/saml.js +181 -0
  130. package/dist/saml.js.map +1 -0
  131. package/dist/services/main.d.ts +22 -0
  132. package/dist/services/main.d.ts.map +1 -0
  133. package/dist/services/main.js +39 -0
  134. package/dist/services/main.js.map +1 -0
  135. package/dist/testing/FakeTransit.d.ts +82 -0
  136. package/dist/testing/FakeTransit.d.ts.map +1 -0
  137. package/dist/testing/FakeTransit.js +160 -0
  138. package/dist/testing/FakeTransit.js.map +1 -0
  139. package/dist/types.d.ts +118 -0
  140. package/dist/types.d.ts.map +1 -0
  141. package/dist/types.js +27 -0
  142. package/dist/types.js.map +1 -0
  143. package/dist/xml.d.ts +72 -0
  144. package/dist/xml.d.ts.map +1 -0
  145. package/dist/xml.js +353 -0
  146. package/dist/xml.js.map +1 -0
  147. package/package.json +73 -0
  148. package/src/Oauth1Driver.ts +216 -0
  149. package/src/Oauth2Driver.ts +271 -0
  150. package/src/TransitManager.ts +145 -0
  151. package/src/TransitProvider.ts +51 -0
  152. package/src/apple.ts +101 -0
  153. package/src/ber.ts +175 -0
  154. package/src/c14n.ts +187 -0
  155. package/src/config.ts +160 -0
  156. package/src/configure.ts +47 -0
  157. package/src/drivers/AppleDriver.ts +69 -0
  158. package/src/drivers/DiscordDriver.ts +50 -0
  159. package/src/drivers/FacebookDriver.ts +53 -0
  160. package/src/drivers/GitHubDriver.ts +91 -0
  161. package/src/drivers/GoogleDriver.ts +38 -0
  162. package/src/drivers/LdapDirectory.ts +185 -0
  163. package/src/drivers/LinkedInDriver.ts +105 -0
  164. package/src/drivers/LinkedInOpenidConnectDriver.ts +33 -0
  165. package/src/drivers/OidcDriver.ts +379 -0
  166. package/src/drivers/SamlDriver.ts +326 -0
  167. package/src/drivers/SpotifyDriver.ts +38 -0
  168. package/src/drivers/TwitterDriver.ts +55 -0
  169. package/src/drivers/TwitterXDriver.ts +53 -0
  170. package/src/dsig.ts +405 -0
  171. package/src/index.ts +85 -0
  172. package/src/jwt.ts +263 -0
  173. package/src/ldap.ts +374 -0
  174. package/src/nodeEnv.ts +31 -0
  175. package/src/oauth1.ts +144 -0
  176. package/src/oidc.ts +208 -0
  177. package/src/quasar.ts +64 -0
  178. package/src/replay.ts +136 -0
  179. package/src/saml.ts +309 -0
  180. package/src/services/main.ts +47 -0
  181. package/src/testing/FakeTransit.ts +233 -0
  182. package/src/types.ts +150 -0
  183. package/src/xml.ts +438 -0
package/src/types.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The shapes every provider answers with, whatever protocol it speaks.
3
+ */
4
+
5
+ export interface OAuthConfig {
6
+ clientId: string;
7
+ clientSecret: string;
8
+ callbackUrl: string;
9
+ scopes?: string[];
10
+ /**
11
+ * Extra parameters appended to the authorize URL — a provider's own knobs
12
+ * (`prompt`, `display`, `show_dialog`, `guild_id`, …) without a config type
13
+ * per provider. They are applied after the driver's own defaults, so this
14
+ * can also override one.
15
+ */
16
+ authorizeParams?: Record<string, string>;
17
+ }
18
+
19
+ /**
20
+ * Whether the provider vouches for the address it returned.
21
+ *
22
+ * This decides whether an application may link the sign-in to an existing
23
+ * account by email. `"unverified"` means anyone able to type that address at
24
+ * the provider now holds it — linking on that basis hands them the account.
25
+ * `"unsupported"` means the provider says nothing either way, which is not the
26
+ * same as saying yes.
27
+ */
28
+ export type EmailVerificationState = "verified" | "unverified" | "unsupported";
29
+
30
+ export interface TransitUser {
31
+ id: string;
32
+ email: string;
33
+ name: string;
34
+ /** The handle the provider shows: a login, a username, a display name. */
35
+ nickName?: string;
36
+ avatarUrl?: string;
37
+ /** Defaults to `"unsupported"` — the answer that assumes nothing. */
38
+ emailVerificationState?: EmailVerificationState;
39
+ raw: Record<string, unknown>;
40
+ }
41
+
42
+ export interface OAuthToken {
43
+ accessToken: string;
44
+ refreshToken?: string;
45
+ expiresIn?: number;
46
+ /**
47
+ * OAuth1 only: the secret every later call has to be signed with. An
48
+ * OAuth1 access token is useless without it.
49
+ */
50
+ tokenSecret?: string;
51
+ }
52
+
53
+ /**
54
+ * What a redirect needs the caller to keep until the user comes back.
55
+ *
56
+ * `state` is what the callback's own `state` must match. `secret` is present
57
+ * for the providers that mint one at redirect time — a PKCE verifier, or an
58
+ * OAuth1 request-token secret — and must be handed back to `callback()`.
59
+ * Store both where you store a session, and pull them once.
60
+ */
61
+ export interface RedirectRequest {
62
+ url: string;
63
+ state: string;
64
+ secret?: string;
65
+ }
66
+
67
+ export interface TransitDriver {
68
+ /**
69
+ * Where to send the user. `secret` is only read by providers that require
70
+ * PKCE, and those refuse to build a URL without it.
71
+ *
72
+ * A provider whose redirect needs a round trip of its own — OAuth1 — cannot
73
+ * answer here at all and throws; use {@link TransitDriver.begin}.
74
+ */
75
+ redirectUrl(state?: string, secret?: string): string;
76
+ /**
77
+ * Where to send the user, plus whatever has to be kept until they come
78
+ * back. Works for every provider, including the ones `redirectUrl` cannot
79
+ * serve, so it is the path to reach for.
80
+ */
81
+ begin?(state?: string): Promise<RedirectRequest>;
82
+ /**
83
+ * Handle the OAuth callback. `state` MUST be validated against the value
84
+ * originally passed to `redirectUrl()` to prevent CSRF login attacks.
85
+ * Throws if state is missing or mismatched.
86
+ *
87
+ * `secret` is the value stored alongside the state at redirect time — a
88
+ * PKCE verifier, or an OAuth1 request-token secret.
89
+ */
90
+ callback(
91
+ code: string,
92
+ state?: string,
93
+ expectedState?: string,
94
+ secret?: string,
95
+ ): Promise<{ user: TransitUser; token: OAuthToken }>;
96
+ /**
97
+ * Read the profile behind a token already held, with no code to exchange.
98
+ * `tokenSecret` is required by OAuth1 providers and ignored by the rest.
99
+ * Optional: a driver that cannot do it simply does not offer it.
100
+ */
101
+ userFromToken?(
102
+ accessToken: string,
103
+ tokenSecret?: string,
104
+ ): Promise<TransitUser>;
105
+ }
106
+
107
+ /**
108
+ * A directory that verifies credentials, rather than a provider a browser is
109
+ * sent to. LDAP and Active Directory are this shape: nothing is redirected,
110
+ * the application asks and the directory answers.
111
+ */
112
+ export interface DirectoryDriver {
113
+ /** Verify a login, and answer with who the directory says this is. */
114
+ authenticate(username: string, password: string): Promise<TransitUser>;
115
+ }
116
+
117
+ /** Either shape a configured provider can take. */
118
+ export type TransitEntry = TransitDriver | DirectoryDriver;
119
+
120
+ /** Whether an entry verifies credentials rather than redirecting. */
121
+ export function isDirectory(entry: TransitEntry): entry is DirectoryDriver {
122
+ return (
123
+ typeof (entry as DirectoryDriver).authenticate === "function" &&
124
+ typeof (entry as TransitDriver).callback !== "function"
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Check the OAuth `state` round-trip, failing CLOSED.
130
+ *
131
+ * The check used to be `if (expectedState && state !== expectedState)`, so a
132
+ * caller that passed no expected state got NO CSRF protection and no sign that
133
+ * it was missing. A driver used directly — without the manager, which already
134
+ * refuses — was therefore open by default. Anything that cannot be verified is
135
+ * refused instead.
136
+ */
137
+ export function assertOAuthState(
138
+ state: string | undefined,
139
+ expectedState: string | undefined,
140
+ ): void {
141
+ if (!expectedState) {
142
+ throw new Error(
143
+ "[transit] OAuth callback requires expectedState for CSRF protection. " +
144
+ "Store the state given to redirectUrl() in the session and pass it here.",
145
+ );
146
+ }
147
+ if (state !== expectedState) {
148
+ throw new Error("OAuth state mismatch — possible CSRF attack");
149
+ }
150
+ }
package/src/xml.ts ADDED
@@ -0,0 +1,438 @@
1
+ /**
2
+ * A namespace-aware XML reader, written for one job: holding a SAML document
3
+ * still enough that a signature over part of it can be checked.
4
+ *
5
+ * It refuses more than it parses, and that is the point.
6
+ *
7
+ * - **No DOCTYPE.** One rule, and it closes external entities (XXE) and the
8
+ * billion-laughs expansion at the same time. A signed assertion has no
9
+ * legitimate reason to carry one.
10
+ * - **No entity references** beyond the five XML defines and numeric ones,
11
+ * for the same reason.
12
+ * - Anything malformed throws rather than being repaired. A parser that
13
+ * guesses at broken input hands a verifier a document the sender never
14
+ * wrote, and the two then disagree about what was signed.
15
+ *
16
+ * The tree keeps the parent link and the namespace declarations exactly where
17
+ * they appeared, because canonicalization needs both.
18
+ */
19
+
20
+ /** The prefix bound by the XML specification itself; never declared. */
21
+ export const XML_NS = "http://www.w3.org/XML/1998/namespace";
22
+ const XMLNS_NS = "http://www.w3.org/2000/xmlns/";
23
+
24
+ export interface XmlNamespace {
25
+ /** `""` for the default namespace. */
26
+ prefix: string;
27
+ uri: string;
28
+ }
29
+
30
+ export interface XmlAttribute {
31
+ prefix: string;
32
+ local: string;
33
+ qname: string;
34
+ /** `""` for an unprefixed attribute, which is in no namespace. */
35
+ namespaceUri: string;
36
+ value: string;
37
+ }
38
+
39
+ export interface XmlElement {
40
+ type: "element";
41
+ prefix: string;
42
+ local: string;
43
+ qname: string;
44
+ namespaceUri: string;
45
+ /** Declarations that appear ON this element, not the ones in scope. */
46
+ namespaces: XmlNamespace[];
47
+ attributes: XmlAttribute[];
48
+ children: XmlNode[];
49
+ parent: XmlElement | undefined;
50
+ }
51
+
52
+ export interface XmlText {
53
+ type: "text";
54
+ value: string;
55
+ }
56
+
57
+ export interface XmlComment {
58
+ type: "comment";
59
+ value: string;
60
+ }
61
+
62
+ export interface XmlInstruction {
63
+ type: "instruction";
64
+ target: string;
65
+ data: string;
66
+ }
67
+
68
+ export type XmlNode = XmlElement | XmlText | XmlComment | XmlInstruction;
69
+
70
+ class XmlError extends Error {
71
+ constructor(message: string) {
72
+ super(`[transit] ${message}`);
73
+ this.name = "XmlError";
74
+ }
75
+ }
76
+
77
+ /** Parse a document and return its root element. */
78
+ export function parseXml(source: string): XmlElement {
79
+ // Line endings are normalised before parsing, as the canonicalization
80
+ // specification requires — otherwise the same document signed on one
81
+ // platform digests differently on another.
82
+ const input = source.replace(/\r\n?/g, "\n");
83
+ const parser = new Parser(input);
84
+ return parser.document();
85
+ }
86
+
87
+ /** The URI a prefix resolves to at this element, or `undefined`. */
88
+ export function resolvePrefix(
89
+ element: XmlElement,
90
+ prefix: string,
91
+ ): string | undefined {
92
+ if (prefix === "xml") return XML_NS;
93
+ for (
94
+ let node: XmlElement | undefined = element;
95
+ node !== undefined;
96
+ node = node.parent
97
+ ) {
98
+ const found = node.namespaces.find((ns) => ns.prefix === prefix);
99
+ if (found) return found.uri;
100
+ }
101
+ return prefix === "" ? "" : undefined;
102
+ }
103
+
104
+ /** Every element under `element`, including it, in document order. */
105
+ export function* walk(element: XmlElement): Generator<XmlElement> {
106
+ yield element;
107
+ for (const child of element.children) {
108
+ if (child.type === "element") yield* walk(child);
109
+ }
110
+ }
111
+
112
+ /** The first element carrying `attribute` with `value`, searched in order. */
113
+ export function findByAttribute(
114
+ root: XmlElement,
115
+ attribute: string,
116
+ value: string,
117
+ ): XmlElement | undefined {
118
+ for (const element of walk(root)) {
119
+ if (
120
+ element.attributes.some((a) => a.local === attribute && a.value === value)
121
+ ) {
122
+ return element;
123
+ }
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /** Direct element children named `local` in `namespaceUri`. */
129
+ export function childrenNamed(
130
+ element: XmlElement,
131
+ namespaceUri: string,
132
+ local: string,
133
+ ): XmlElement[] {
134
+ return element.children.filter(
135
+ (child): child is XmlElement =>
136
+ child.type === "element" &&
137
+ child.local === local &&
138
+ child.namespaceUri === namespaceUri,
139
+ );
140
+ }
141
+
142
+ /** The concatenated text of an element, descendants included. */
143
+ export function textOf(element: XmlElement): string {
144
+ let out = "";
145
+ for (const child of element.children) {
146
+ if (child.type === "text") out += child.value;
147
+ else if (child.type === "element") out += textOf(child);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ class Parser {
153
+ #at = 0;
154
+ constructor(private readonly source: string) {}
155
+
156
+ document(): XmlElement {
157
+ let root: XmlElement | undefined;
158
+ while (this.#at < this.source.length) {
159
+ this.#skipSpace();
160
+ if (this.#at >= this.source.length) break;
161
+ if (this.#peek("<?xml")) {
162
+ this.#skipTo("?>");
163
+ continue;
164
+ }
165
+ if (this.#peek("<!DOCTYPE") || this.#peek("<!ENTITY")) {
166
+ throw new XmlError(
167
+ "the document carries a DOCTYPE, which this refuses: it is how external entities and expansion bombs get in.",
168
+ );
169
+ }
170
+ if (this.#peek("<!--")) {
171
+ this.#skipTo("-->");
172
+ continue;
173
+ }
174
+ if (this.#peek("<?")) {
175
+ this.#skipTo("?>");
176
+ continue;
177
+ }
178
+ if (root !== undefined) {
179
+ throw new XmlError("the document has more than one root element");
180
+ }
181
+ root = this.#element(undefined);
182
+ }
183
+ if (root === undefined) throw new XmlError("the document has no element");
184
+ return root;
185
+ }
186
+
187
+ #element(parent: XmlElement | undefined): XmlElement {
188
+ this.#expect("<");
189
+ const qname = this.#name();
190
+ const element: XmlElement = {
191
+ type: "element",
192
+ prefix: "",
193
+ local: "",
194
+ qname,
195
+ namespaceUri: "",
196
+ namespaces: [],
197
+ attributes: [],
198
+ children: [],
199
+ parent,
200
+ };
201
+
202
+ // Attributes are read before anything is resolved: a declaration on this
203
+ // element binds the element's own prefix too.
204
+ const raw: Array<{ qname: string; value: string }> = [];
205
+ for (;;) {
206
+ this.#skipSpace();
207
+ if (this.#peek("/>") || this.#peek(">")) break;
208
+ const name = this.#name();
209
+ this.#skipSpace();
210
+ this.#expect("=");
211
+ this.#skipSpace();
212
+ raw.push({ qname: name, value: this.#attributeValue() });
213
+ }
214
+
215
+ for (const { qname: name, value } of raw) {
216
+ if (name === "xmlns") {
217
+ element.namespaces.push({ prefix: "", uri: value });
218
+ } else if (name.startsWith("xmlns:")) {
219
+ const prefix = name.slice(6);
220
+ if (prefix === "") throw new XmlError("empty namespace prefix");
221
+ element.namespaces.push({ prefix, uri: value });
222
+ }
223
+ }
224
+ if (
225
+ new Set(element.namespaces.map((n) => n.prefix)).size !==
226
+ element.namespaces.length
227
+ ) {
228
+ throw new XmlError(`element <${qname}> declares a prefix twice`);
229
+ }
230
+
231
+ const [prefix, local] = split(qname);
232
+ element.prefix = prefix;
233
+ element.local = local;
234
+ const uri = resolvePrefix(element, prefix);
235
+ if (uri === undefined) {
236
+ throw new XmlError(
237
+ `element <${qname}> uses the unbound prefix '${prefix}'`,
238
+ );
239
+ }
240
+ element.namespaceUri = uri;
241
+
242
+ for (const { qname: name, value } of raw) {
243
+ if (name === "xmlns" || name.startsWith("xmlns:")) continue;
244
+ const [attrPrefix, attrLocal] = split(name);
245
+ // An unprefixed attribute is in NO namespace — it does not pick up the
246
+ // default one. Getting this wrong changes canonical output.
247
+ let attrUri = "";
248
+ if (attrPrefix !== "") {
249
+ const resolved = resolvePrefix(element, attrPrefix);
250
+ if (resolved === undefined) {
251
+ throw new XmlError(
252
+ `attribute '${name}' uses the unbound prefix '${attrPrefix}'`,
253
+ );
254
+ }
255
+ attrUri = resolved;
256
+ }
257
+ element.attributes.push({
258
+ prefix: attrPrefix,
259
+ local: attrLocal,
260
+ qname: name,
261
+ namespaceUri: attrUri === XMLNS_NS ? "" : attrUri,
262
+ value,
263
+ });
264
+ }
265
+ if (
266
+ new Set(element.attributes.map((a) => `${a.namespaceUri}|${a.local}`))
267
+ .size !== element.attributes.length
268
+ ) {
269
+ throw new XmlError(`element <${qname}> carries the same attribute twice`);
270
+ }
271
+
272
+ if (this.#take("/>")) return element;
273
+ this.#expect(">");
274
+
275
+ for (;;) {
276
+ if (this.#at >= this.source.length) {
277
+ throw new XmlError(`<${qname}> is never closed`);
278
+ }
279
+ if (this.#peek("</")) {
280
+ this.#expect("</");
281
+ const closing = this.#name();
282
+ if (closing !== qname) {
283
+ throw new XmlError(`<${qname}> is closed by </${closing}>`);
284
+ }
285
+ this.#skipSpace();
286
+ this.#expect(">");
287
+ return element;
288
+ }
289
+ if (this.#peek("<!--")) {
290
+ const end = this.source.indexOf("-->", this.#at);
291
+ if (end === -1) throw new XmlError("a comment is never closed");
292
+ element.children.push({
293
+ type: "comment",
294
+ value: this.source.slice(this.#at + 4, end),
295
+ });
296
+ this.#at = end + 3;
297
+ continue;
298
+ }
299
+ if (this.#peek("<![CDATA[")) {
300
+ const end = this.source.indexOf("]]>", this.#at);
301
+ if (end === -1) throw new XmlError("a CDATA section is never closed");
302
+ element.children.push({
303
+ type: "text",
304
+ value: this.source.slice(this.#at + 9, end),
305
+ });
306
+ this.#at = end + 3;
307
+ continue;
308
+ }
309
+ if (this.#peek("<!")) {
310
+ throw new XmlError("the document carries a declaration this refuses");
311
+ }
312
+ if (this.#peek("<?")) {
313
+ const end = this.source.indexOf("?>", this.#at);
314
+ if (end === -1)
315
+ throw new XmlError("a processing instruction is never closed");
316
+ const body = this.source.slice(this.#at + 2, end);
317
+ const space = body.search(/\s/);
318
+ element.children.push({
319
+ type: "instruction",
320
+ target: space === -1 ? body : body.slice(0, space),
321
+ data: space === -1 ? "" : body.slice(space + 1),
322
+ });
323
+ this.#at = end + 2;
324
+ continue;
325
+ }
326
+ if (this.#peek("<")) {
327
+ element.children.push(this.#element(element));
328
+ continue;
329
+ }
330
+ const next = this.source.indexOf("<", this.#at);
331
+ const end = next === -1 ? this.source.length : next;
332
+ element.children.push({
333
+ type: "text",
334
+ value: resolveEntities(this.source.slice(this.#at, end)),
335
+ });
336
+ this.#at = end;
337
+ }
338
+ }
339
+
340
+ #attributeValue(): string {
341
+ const quote = this.source[this.#at];
342
+ if (quote !== '"' && quote !== "'") {
343
+ throw new XmlError("an attribute value is not quoted");
344
+ }
345
+ this.#at += 1;
346
+ const end = this.source.indexOf(quote, this.#at);
347
+ if (end === -1) throw new XmlError("an attribute value is never closed");
348
+ const raw = this.source.slice(this.#at, end);
349
+ this.#at = end + 1;
350
+ if (raw.includes("<")) {
351
+ throw new XmlError("an attribute value contains '<'");
352
+ }
353
+ // Attribute-value normalisation, and the order matters: a LITERAL tab or
354
+ // newline becomes a space, while `&#x9;` stays a tab. Unescaping first
355
+ // would flatten the reference too, and the canonical form would then
356
+ // differ from the signer's by exactly that byte.
357
+ return resolveEntities(raw.replace(/[\t\n]/g, " "));
358
+ }
359
+
360
+ #name(): string {
361
+ const match = /^[A-Za-z_:][\w.\-:]*/.exec(this.source.slice(this.#at));
362
+ if (!match) throw new XmlError(`expected a name at offset ${this.#at}`);
363
+ this.#at += match[0].length;
364
+ return match[0];
365
+ }
366
+
367
+ #skipSpace(): void {
368
+ while (
369
+ this.#at < this.source.length &&
370
+ /\s/.test(this.source[this.#at] as string)
371
+ ) {
372
+ this.#at += 1;
373
+ }
374
+ }
375
+
376
+ #skipTo(marker: string): void {
377
+ const end = this.source.indexOf(marker, this.#at);
378
+ if (end === -1) throw new XmlError(`'${marker}' is missing`);
379
+ this.#at = end + marker.length;
380
+ }
381
+
382
+ #peek(text: string): boolean {
383
+ return this.source.startsWith(text, this.#at);
384
+ }
385
+
386
+ #take(text: string): boolean {
387
+ if (!this.#peek(text)) return false;
388
+ this.#at += text.length;
389
+ return true;
390
+ }
391
+
392
+ #expect(text: string): void {
393
+ if (!this.#take(text)) {
394
+ throw new XmlError(`expected '${text}' at offset ${this.#at}`);
395
+ }
396
+ }
397
+ }
398
+
399
+ function split(qname: string): [prefix: string, local: string] {
400
+ const colon = qname.indexOf(":");
401
+ return colon === -1
402
+ ? ["", qname]
403
+ : [qname.slice(0, colon), qname.slice(colon + 1)];
404
+ }
405
+
406
+ /**
407
+ * Resolve the five entities XML defines, and numeric references. Anything else
408
+ * is refused rather than passed through: an unknown entity in a signed document
409
+ * means the sender expected a declaration this parser will not read.
410
+ */
411
+ function resolveEntities(text: string): string {
412
+ return text.replace(
413
+ /&(#x?[0-9A-Fa-f]+|[A-Za-z]+);/g,
414
+ (whole, body: string) => {
415
+ switch (body) {
416
+ case "amp":
417
+ return "&";
418
+ case "lt":
419
+ return "<";
420
+ case "gt":
421
+ return ">";
422
+ case "quot":
423
+ return '"';
424
+ case "apos":
425
+ return "'";
426
+ default:
427
+ break;
428
+ }
429
+ if (body.startsWith("#x") || body.startsWith("#X")) {
430
+ return String.fromCodePoint(Number.parseInt(body.slice(2), 16));
431
+ }
432
+ if (body.startsWith("#")) {
433
+ return String.fromCodePoint(Number.parseInt(body.slice(1), 10));
434
+ }
435
+ throw new XmlError(`unknown entity '${whole}'`);
436
+ },
437
+ );
438
+ }