@c9up/visa 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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/dist/VisaManager.d.ts +94 -0
  4. package/dist/VisaManager.d.ts.map +1 -0
  5. package/dist/VisaManager.js +216 -0
  6. package/dist/VisaManager.js.map +1 -0
  7. package/dist/VisaProvider.d.ts +41 -0
  8. package/dist/VisaProvider.d.ts.map +1 -0
  9. package/dist/VisaProvider.js +153 -0
  10. package/dist/VisaProvider.js.map +1 -0
  11. package/dist/augmentations.d.ts +25 -0
  12. package/dist/augmentations.d.ts.map +1 -0
  13. package/dist/augmentations.js +19 -0
  14. package/dist/augmentations.js.map +1 -0
  15. package/dist/authorize.d.ts +78 -0
  16. package/dist/authorize.d.ts.map +1 -0
  17. package/dist/authorize.js +149 -0
  18. package/dist/authorize.js.map +1 -0
  19. package/dist/clients.d.ts +70 -0
  20. package/dist/clients.d.ts.map +1 -0
  21. package/dist/clients.js +177 -0
  22. package/dist/clients.js.map +1 -0
  23. package/dist/config.d.ts +30 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +8 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/configure.d.ts +28 -0
  28. package/dist/configure.d.ts.map +1 -0
  29. package/dist/configure.js +24 -0
  30. package/dist/configure.js.map +1 -0
  31. package/dist/crypto.d.ts +35 -0
  32. package/dist/crypto.d.ts.map +1 -0
  33. package/dist/crypto.js +56 -0
  34. package/dist/crypto.js.map +1 -0
  35. package/dist/errors.d.ts +47 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +50 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/index.d.ts +25 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +23 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/introspect.d.ts +32 -0
  44. package/dist/introspect.d.ts.map +1 -0
  45. package/dist/introspect.js +110 -0
  46. package/dist/introspect.js.map +1 -0
  47. package/dist/services/main.d.ts +29 -0
  48. package/dist/services/main.d.ts.map +1 -0
  49. package/dist/services/main.js +48 -0
  50. package/dist/services/main.js.map +1 -0
  51. package/dist/store.d.ts +44 -0
  52. package/dist/store.d.ts.map +1 -0
  53. package/dist/store.js +12 -0
  54. package/dist/store.js.map +1 -0
  55. package/dist/stores/memory.d.ts +34 -0
  56. package/dist/stores/memory.d.ts.map +1 -0
  57. package/dist/stores/memory.js +100 -0
  58. package/dist/stores/memory.js.map +1 -0
  59. package/dist/stubs.d.ts +14 -0
  60. package/dist/stubs.d.ts.map +1 -0
  61. package/dist/stubs.js +15 -0
  62. package/dist/stubs.js.map +1 -0
  63. package/dist/testing/main.d.ts +49 -0
  64. package/dist/testing/main.d.ts.map +1 -0
  65. package/dist/testing/main.js +77 -0
  66. package/dist/testing/main.js.map +1 -0
  67. package/dist/token.d.ts +20 -0
  68. package/dist/token.d.ts.map +1 -0
  69. package/dist/token.js +212 -0
  70. package/dist/token.js.map +1 -0
  71. package/dist/types.d.ts +123 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +9 -0
  74. package/dist/types.js.map +1 -0
  75. package/package.json +83 -0
  76. package/src/VisaManager.ts +346 -0
  77. package/src/VisaProvider.ts +197 -0
  78. package/src/augmentations.ts +29 -0
  79. package/src/authorize.ts +250 -0
  80. package/src/clients.ts +231 -0
  81. package/src/config.ts +34 -0
  82. package/src/configure.ts +42 -0
  83. package/src/crypto.ts +59 -0
  84. package/src/errors.ts +79 -0
  85. package/src/index.ts +67 -0
  86. package/src/introspect.ts +139 -0
  87. package/src/services/main.ts +57 -0
  88. package/src/store.ts +56 -0
  89. package/src/stores/memory.ts +118 -0
  90. package/src/stubs.ts +16 -0
  91. package/src/testing/main.ts +119 -0
  92. package/src/token.ts +325 -0
  93. package/src/types.ts +134 -0
  94. package/stubs/config/visa.stub +25 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The authorization endpoint.
3
+ *
4
+ * It is the half a human sees, and the half where an error must NOT be
5
+ * redirected: if the redirect URI is missing, unregistered or malformed, the
6
+ * server tells the user and stops (§4.1.2.1). Bouncing the error to an
7
+ * unverified URI is how an attacker learns that a client exists and gets a
8
+ * page of their choosing loaded in the user's session.
9
+ */
10
+
11
+ import {
12
+ assertGrantAllowed,
13
+ redirectUriMatches,
14
+ resolveScopes,
15
+ } from "./clients.js";
16
+ import { hashSecret, randomToken } from "./crypto.js";
17
+ import { OAuthError } from "./errors.js";
18
+ import type { VisaStore } from "./store.js";
19
+ import type { AuthorizationCode, Client } from "./types.js";
20
+
21
+ /** The query as it arrived, untrusted. */
22
+ export interface AuthorizationRequest {
23
+ response_type?: string;
24
+ client_id?: string;
25
+ redirect_uri?: string;
26
+ scope?: string;
27
+ state?: string;
28
+ code_challenge?: string;
29
+ code_challenge_method?: string;
30
+ nonce?: string;
31
+ /** A client may ask to always see the consent screen. */
32
+ prompt?: string;
33
+ }
34
+
35
+ /** A request that passed every check, with the values it resolved to. */
36
+ export interface ValidatedRequest {
37
+ client: Client;
38
+ redirectUri: string;
39
+ scopes: string[];
40
+ state: string | undefined;
41
+ codeChallenge: string;
42
+ codeChallengeMethod: "S256" | "plain";
43
+ nonce: string | undefined;
44
+ }
45
+
46
+ /**
47
+ * A failure that cannot be redirected — the user must be told directly.
48
+ *
49
+ * Separate from `OAuthError` because the CALLER has to treat it differently:
50
+ * render a page, never `Location:`.
51
+ */
52
+ export class UnredirectableError extends Error {
53
+ readonly error: OAuthError;
54
+ constructor(error: OAuthError) {
55
+ super(error.message);
56
+ this.name = "UnredirectableError";
57
+ this.error = error;
58
+ }
59
+ }
60
+
61
+ export interface AuthorizeOptions {
62
+ /** Lifetime of the code. Short on purpose — see `issueAuthorizationCode`. */
63
+ codeTtlSeconds?: number;
64
+ /**
65
+ * Accept `plain` as a code challenge method.
66
+ *
67
+ * NAMED DEVIATION — the spec allows `plain`; this refuses it by default.
68
+ * `plain` puts the verifier in the authorization request, so anything that
69
+ * can read that request (a log, a referrer, a proxy) can complete the
70
+ * exchange, which is precisely what PKCE exists to stop. Every client that
71
+ * can hash can use S256.
72
+ */
73
+ allowPlainChallenge?: boolean;
74
+ }
75
+
76
+ /**
77
+ * Check an authorization request. Throws `UnredirectableError` when the error
78
+ * must not be bounced back, `OAuthError` when it may.
79
+ */
80
+ export async function validateAuthorizationRequest(
81
+ request: AuthorizationRequest,
82
+ store: VisaStore,
83
+ options: AuthorizeOptions = {},
84
+ ): Promise<ValidatedRequest> {
85
+ // Order matters: everything that decides WHERE an error may go has to be
86
+ // settled before any error is produced.
87
+ if (request.client_id === undefined || request.client_id === "") {
88
+ throw new UnredirectableError(
89
+ new OAuthError("invalid_request", "client_id is required."),
90
+ );
91
+ }
92
+ const client = await store.findClient(request.client_id);
93
+ if (client === null) {
94
+ throw new UnredirectableError(
95
+ new OAuthError("invalid_request", "Unknown client."),
96
+ );
97
+ }
98
+
99
+ const redirectUri = resolveRedirectUri(request.redirect_uri, client);
100
+
101
+ // From here the redirect URI is verified, so an error may travel back.
102
+ if (request.response_type !== "code") {
103
+ throw new OAuthError(
104
+ request.response_type === undefined
105
+ ? "invalid_request"
106
+ : "unsupported_response_type",
107
+ // The implicit grant is gone in 2.1, and `token` is its response
108
+ // type — saying so beats a blank refusal for whoever is porting.
109
+ request.response_type === "token"
110
+ ? "The implicit grant is not supported; use response_type=code."
111
+ : "Only response_type=code is supported.",
112
+ );
113
+ }
114
+
115
+ assertGrantAllowed(client, "authorization_code");
116
+
117
+ const { codeChallenge, codeChallengeMethod } = readChallenge(
118
+ request,
119
+ options,
120
+ );
121
+ const scopes = resolveScopes(request.scope, client);
122
+
123
+ return {
124
+ client,
125
+ redirectUri,
126
+ scopes,
127
+ state: request.state,
128
+ codeChallenge,
129
+ codeChallengeMethod,
130
+ nonce: request.nonce,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Which redirect URI this request lands on.
136
+ *
137
+ * Omitting it is allowed only when the client registered exactly one — with
138
+ * two or more, "the first one" would be a choice the server makes on the
139
+ * client's behalf, and the wrong choice sends the code elsewhere.
140
+ */
141
+ function resolveRedirectUri(
142
+ requested: string | undefined,
143
+ client: Client,
144
+ ): string {
145
+ if (client.redirectUris.length === 0) {
146
+ throw new UnredirectableError(
147
+ new OAuthError("invalid_request", "This client has no redirect URI."),
148
+ );
149
+ }
150
+ if (requested === undefined || requested === "") {
151
+ const only = client.redirectUris[0];
152
+ if (client.redirectUris.length > 1 || only === undefined) {
153
+ throw new UnredirectableError(
154
+ new OAuthError(
155
+ "invalid_request",
156
+ "redirect_uri is required when a client registers more than one.",
157
+ ),
158
+ );
159
+ }
160
+ return only;
161
+ }
162
+ if (!redirectUriMatches(requested, client.redirectUris)) {
163
+ throw new UnredirectableError(
164
+ new OAuthError("invalid_request", "redirect_uri is not registered."),
165
+ );
166
+ }
167
+ return requested;
168
+ }
169
+
170
+ function readChallenge(
171
+ request: AuthorizationRequest,
172
+ options: AuthorizeOptions,
173
+ ): { codeChallenge: string; codeChallengeMethod: "S256" | "plain" } {
174
+ const challenge = request.code_challenge;
175
+ if (challenge === undefined || challenge === "") {
176
+ // Mandatory in 2.1 for every client, confidential ones included.
177
+ throw new OAuthError("invalid_request", "code_challenge is required.");
178
+ }
179
+ const method = request.code_challenge_method ?? "plain";
180
+ if (method === "S256")
181
+ return { codeChallenge: challenge, codeChallengeMethod: "S256" };
182
+ if (method === "plain" && options.allowPlainChallenge === true) {
183
+ return { codeChallenge: challenge, codeChallengeMethod: "plain" };
184
+ }
185
+ throw new OAuthError(
186
+ "invalid_request",
187
+ "code_challenge_method must be S256.",
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Mint the code, once the user is known and has consented.
193
+ *
194
+ * Thirty seconds by default. A code is exchanged by a server the client
195
+ * controls, milliseconds after the redirect — a long window buys the client
196
+ * nothing and gives anything that can read a URL (a log, a referrer header, a
197
+ * shoulder) time to use it.
198
+ */
199
+ export async function issueAuthorizationCode(
200
+ validated: ValidatedRequest,
201
+ userId: string,
202
+ store: VisaStore,
203
+ options: AuthorizeOptions = {},
204
+ now: Date = new Date(),
205
+ ): Promise<string> {
206
+ const code = randomToken();
207
+ const ttl = options.codeTtlSeconds ?? 30;
208
+ const record: AuthorizationCode = {
209
+ codeHash: hashSecret(code),
210
+ clientId: validated.client.id,
211
+ userId,
212
+ redirectUri: validated.redirectUri,
213
+ scopes: validated.scopes,
214
+ codeChallenge: validated.codeChallenge,
215
+ codeChallengeMethod: validated.codeChallengeMethod,
216
+ expiresAt: new Date(now.getTime() + ttl * 1000),
217
+ ...(validated.nonce === undefined ? {} : { nonce: validated.nonce }),
218
+ };
219
+ await store.saveAuthorizationCode(record);
220
+ return code;
221
+ }
222
+
223
+ /** Build the redirect a successful authorization ends on. */
224
+ export function successRedirect(
225
+ validated: ValidatedRequest,
226
+ code: string,
227
+ ): string {
228
+ const url = new URL(validated.redirectUri);
229
+ url.searchParams.set("code", code);
230
+ // Returned verbatim when the client sent one, absent when it did not —
231
+ // inventing a state would break the client's own CSRF check.
232
+ if (validated.state !== undefined)
233
+ url.searchParams.set("state", validated.state);
234
+ return url.toString();
235
+ }
236
+
237
+ /** Build the redirect a refused or failed authorization ends on. */
238
+ export function errorRedirect(
239
+ redirectUri: string,
240
+ error: OAuthError,
241
+ state?: string,
242
+ ): string {
243
+ const url = new URL(redirectUri);
244
+ url.searchParams.set("error", error.code);
245
+ if (error.description !== undefined) {
246
+ url.searchParams.set("error_description", error.description);
247
+ }
248
+ if (state !== undefined) url.searchParams.set("state", state);
249
+ return url.toString();
250
+ }
package/src/clients.ts ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Who is asking, and are they allowed to ask that.
3
+ *
4
+ * Three checks live here and each one closes a known attack: the redirect URI
5
+ * comparison (an open redirect on the client's domain turns into a stolen
6
+ * code), the client authentication (a leaked client_id is not a credential),
7
+ * and the scope narrowing (a client must not be able to grant itself more
8
+ * than it was registered for).
9
+ */
10
+
11
+ import { secretMatches } from "./crypto.js";
12
+ import { OAuthError } from "./errors.js";
13
+ import type { Client, ClientAuthMethod, GrantType } from "./types.js";
14
+
15
+ /**
16
+ * Does this redirect URI match one the client registered?
17
+ *
18
+ * EXACT string comparison, as OAuth 2.1 §2.3.1 requires — with the single
19
+ * exception it names: a loopback redirect may differ in its port, because a
20
+ * native app cannot know in advance which port the OS will hand it.
21
+ *
22
+ * No prefix matching, no wildcard, no "same origin is close enough": every
23
+ * one of those has a published attack where the code lands on a URL the
24
+ * attacker controls.
25
+ */
26
+ export function redirectUriMatches(
27
+ candidate: string,
28
+ registered: readonly string[],
29
+ ): boolean {
30
+ if (registered.includes(candidate)) return true;
31
+
32
+ let url: URL;
33
+ try {
34
+ url = new URL(candidate);
35
+ } catch {
36
+ return false;
37
+ }
38
+ if (!isLoopback(url.hostname)) return false;
39
+
40
+ return registered.some((entry) => {
41
+ let known: URL;
42
+ try {
43
+ known = new URL(entry);
44
+ } catch {
45
+ return false;
46
+ }
47
+ if (!isLoopback(known.hostname)) return false;
48
+ // Everything but the port has to be identical.
49
+ return (
50
+ known.protocol === url.protocol &&
51
+ known.hostname === url.hostname &&
52
+ known.pathname === url.pathname &&
53
+ known.search === url.search
54
+ );
55
+ });
56
+ }
57
+
58
+ function isLoopback(hostname: string): boolean {
59
+ return hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
60
+ }
61
+
62
+ /** Credentials as they arrived, from either place the spec allows. */
63
+ export interface ClientCredentials {
64
+ clientId?: string;
65
+ clientSecret?: string;
66
+ /**
67
+ * WHERE they came from.
68
+ *
69
+ * A client registers one authentication method and must use that one: a
70
+ * client declared `client_secret_basic` sending its secret in the body is
71
+ * either misconfigured or being replayed by something that could only read
72
+ * the body. Accepting both makes the registered method decorative.
73
+ */
74
+ method?: ClientAuthMethod;
75
+ /** The raw `Authorization` header, if there was one. */
76
+ authorization?: string;
77
+ }
78
+
79
+ /**
80
+ * Read `client_id` / `client_secret` out of a request.
81
+ *
82
+ * A client MUST NOT use more than one authentication method in a single
83
+ * request (§2.4): sending both Basic and body credentials is a sign of a
84
+ * confused proxy or of an attempt to have one of them ignored, so it is
85
+ * refused rather than resolved by precedence.
86
+ */
87
+ export function readCredentials(input: {
88
+ authorization?: string;
89
+ body: Record<string, unknown>;
90
+ }): ClientCredentials {
91
+ const basic = parseBasic(input.authorization);
92
+ const bodyId = stringOrUndefined(input.body.client_id);
93
+ const bodySecret = stringOrUndefined(input.body.client_secret);
94
+
95
+ if (basic !== undefined && bodySecret !== undefined) {
96
+ throw new OAuthError(
97
+ "invalid_request",
98
+ "More than one client authentication method was used.",
99
+ );
100
+ }
101
+ if (basic !== undefined) {
102
+ // A body client_id that disagrees with the header is not a mismatch to
103
+ // resolve — it is two different claims about who is asking.
104
+ if (bodyId !== undefined && bodyId !== basic.clientId) {
105
+ throw new OAuthError(
106
+ "invalid_request",
107
+ "The client_id in the body does not match the one authenticated.",
108
+ );
109
+ }
110
+ return basic;
111
+ }
112
+ return {
113
+ clientId: bodyId,
114
+ ...(bodySecret === undefined
115
+ ? { method: "none" as const }
116
+ : { clientSecret: bodySecret, method: "client_secret_post" as const }),
117
+ };
118
+ }
119
+
120
+ function parseBasic(header?: string): ClientCredentials | undefined {
121
+ if (header === undefined) return undefined;
122
+ const [scheme, encoded] = header.split(" ");
123
+ if (scheme?.toLowerCase() !== "basic" || encoded === undefined) {
124
+ return undefined;
125
+ }
126
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
127
+ const separator = decoded.indexOf(":");
128
+ if (separator === -1) {
129
+ throw new OAuthError("invalid_client", "Malformed Basic credentials.");
130
+ }
131
+ // RFC 6749 §2.3.1 form-encodes both halves before base64. A malformed
132
+ // escape throws `URIError`, which would leave the endpoint answering 500
133
+ // to anything that sends `Basic %zz` — an unauthenticated caller choosing
134
+ // the failure mode.
135
+ try {
136
+ return {
137
+ clientId: decodeURIComponent(decoded.slice(0, separator)),
138
+ clientSecret: decodeURIComponent(decoded.slice(separator + 1)),
139
+ method: "client_secret_basic",
140
+ };
141
+ } catch {
142
+ throw new OAuthError("invalid_client", "Malformed Basic credentials.");
143
+ }
144
+ }
145
+
146
+ function stringOrUndefined(value: unknown): string | undefined {
147
+ return typeof value === "string" && value !== "" ? value : undefined;
148
+ }
149
+
150
+ /**
151
+ * Authenticate a client for the token endpoint.
152
+ *
153
+ * Every failure answers `invalid_client` with no detail — "no such client",
154
+ * "wrong secret" and "that client has no secret" are the same sentence to the
155
+ * caller, because telling them apart is how a client list gets enumerated.
156
+ */
157
+ export async function authenticateClient(
158
+ credentials: ClientCredentials,
159
+ load: (id: string) => Promise<Client | null>,
160
+ ): Promise<Client> {
161
+ if (credentials.clientId === undefined) {
162
+ throw new OAuthError("invalid_client", "Client authentication failed.");
163
+ }
164
+ const client = await load(credentials.clientId);
165
+ if (client === null) {
166
+ throw new OAuthError("invalid_client", "Client authentication failed.");
167
+ }
168
+
169
+ // The registered method is the only one accepted. Checked before the
170
+ // secret so a client cannot pick the weaker of two paths.
171
+ if (
172
+ credentials.method !== undefined &&
173
+ credentials.method !== client.tokenEndpointAuthMethod
174
+ ) {
175
+ throw new OAuthError("invalid_client", "Client authentication failed.");
176
+ }
177
+
178
+ if (client.tokenEndpointAuthMethod === "none") {
179
+ // A public client authenticates by not authenticating. Accepting a
180
+ // secret here would let one be registered as public and used as
181
+ // confidential, which is a downgrade the client chooses.
182
+ if (credentials.clientSecret !== undefined) {
183
+ throw new OAuthError("invalid_client", "Client authentication failed.");
184
+ }
185
+ return client;
186
+ }
187
+
188
+ if (
189
+ credentials.clientSecret === undefined ||
190
+ client.secretHash === undefined ||
191
+ !secretMatches(credentials.clientSecret, client.secretHash)
192
+ ) {
193
+ throw new OAuthError("invalid_client", "Client authentication failed.");
194
+ }
195
+ return client;
196
+ }
197
+
198
+ /** Is this grant one the client was registered for? */
199
+ export function assertGrantAllowed(client: Client, grant: GrantType): void {
200
+ if (client.grantTypes.includes(grant)) return;
201
+ throw new OAuthError(
202
+ "unauthorized_client",
203
+ `This client may not use the ${grant} grant.`,
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Narrow a requested scope against what the client may ever have.
209
+ *
210
+ * Absent, the request gets the client's registered set — never "everything".
211
+ * A scope the client was not registered for is refused rather than dropped:
212
+ * silently granting less than asked is how a client ends up believing it has
213
+ * a permission it does not.
214
+ */
215
+ export function resolveScopes(
216
+ requested: string | undefined,
217
+ client: Client,
218
+ ): string[] {
219
+ if (requested === undefined || requested.trim() === "") {
220
+ return [...client.scopes];
221
+ }
222
+ const asked = requested.split(/\s+/).filter((scope) => scope !== "");
223
+ const unknown = asked.filter((scope) => !client.scopes.includes(scope));
224
+ if (unknown.length > 0) {
225
+ throw new OAuthError(
226
+ "invalid_scope",
227
+ `Not registered for: ${unknown.join(" ")}`,
228
+ );
229
+ }
230
+ return asked;
231
+ }
package/src/config.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `config/visa.ts` — the Adonis shape: a `defineConfig` that returns what the
3
+ * provider reads out of the config store.
4
+ */
5
+
6
+ import type { VisaStore } from "./store.js";
7
+
8
+ export interface VisaConfigInput {
9
+ /**
10
+ * The public URL this server answers on — `https://auth.example.com`.
11
+ *
12
+ * It is the `iss` every token is bound to, so it is not cosmetic: a client
13
+ * that validates the issuer rejects tokens minted under a different one.
14
+ */
15
+ issuer: string;
16
+ /** Where clients, codes, tokens and consents live. */
17
+ store: VisaStore;
18
+ /** Seconds. Default 3600 — an hour is long enough to be useful, short
19
+ * enough that a leaked token is not a standing invitation. */
20
+ accessTokenTtlSeconds?: number;
21
+ /** Seconds. Default 30 days. */
22
+ refreshTokenTtlSeconds?: number;
23
+ /** Seconds. Default 30 — a code is exchanged in milliseconds. */
24
+ codeTtlSeconds?: number;
25
+ issueRefreshTokens?: boolean;
26
+ /** See `AuthorizeOptions.allowPlainChallenge` — off, and stay off. */
27
+ allowPlainChallenge?: boolean;
28
+ /** Where the endpoints are mounted. Default `/oauth`. */
29
+ prefix?: string;
30
+ }
31
+
32
+ export function defineConfig(config: VisaConfigInput): VisaConfigInput {
33
+ return config;
34
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `ream configure @c9up/visa` — wire the server in one command.
3
+ *
4
+ * The provider alone is not enough: it reads `config/visa.ts` and refuses to
5
+ * boot without one, on purpose — an authorization server that invented its own
6
+ * issuer would mint tokens nobody can validate.
7
+ *
8
+ * What it does NOT write is the store and the `/authorize` route. Both are
9
+ * decisions rather than boilerplate, and a generated guess at either is wrong
10
+ * in a way that only shows up in production: a memory store loses every
11
+ * session on deploy, and a consent screen nobody wrote grants silently.
12
+ */
13
+
14
+ import { stubsRoot } from "./stubs.js";
15
+
16
+ interface Codemods {
17
+ addProvider(importPath: string): Promise<void>;
18
+ addEnvVars(vars: Record<string, string>): Promise<void>;
19
+ writeFile(
20
+ filePath: string,
21
+ content: string,
22
+ options?: { force?: boolean },
23
+ ): Promise<void>;
24
+ makeUsingStub(
25
+ stubsRoot: string,
26
+ stubPath: string,
27
+ state?: Record<string, string | number | boolean>,
28
+ options?: { force?: boolean },
29
+ ): Promise<{ path: string; contents: string }>;
30
+ }
31
+
32
+ export async function configure(codemods: Codemods): Promise<void> {
33
+ // The config below reads this, so it is declared here. Writing the file
34
+ // without it leaves an application whose config asks the environment for
35
+ // something nothing ever put there.
36
+ await codemods.addEnvVars({
37
+ VISA_ISSUER: "http://localhost:3333",
38
+ });
39
+
40
+ await codemods.addProvider("@c9up/visa/provider");
41
+ await codemods.makeUsingStub(stubsRoot, "config/visa.stub");
42
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Every secret in this package goes through here.
3
+ *
4
+ * `node:crypto` only — a dependency in the path that mints and compares
5
+ * credentials is a dependency that can replace them.
6
+ */
7
+
8
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
9
+
10
+ /**
11
+ * 256 bits of randomness, base64url.
12
+ *
13
+ * The length is not a preference: a token is guessed offline, so its entropy
14
+ * is the only thing standing between an attacker and someone's session.
15
+ */
16
+ export function randomToken(bytes = 32): string {
17
+ return randomBytes(bytes).toString("base64url");
18
+ }
19
+
20
+ /** SHA-256, base64url — the shape PKCE's `S256` needs (RFC 7636 §4.2). */
21
+ export function sha256(value: string): string {
22
+ return createHash("sha256").update(value).digest("base64url");
23
+ }
24
+
25
+ /**
26
+ * What goes in the store for a token or a client secret.
27
+ *
28
+ * SHA-256 with no salt and no stretching, DELIBERATELY: these are values this
29
+ * package generated with 256 bits of entropy, so there is no dictionary to run
30
+ * and nothing for a slow hash to buy. Stretching here would only make every
31
+ * token lookup expensive — and a token lookup happens on every single request.
32
+ * A USER password is the opposite case and belongs in sigil.
33
+ */
34
+ export function hashSecret(secret: string): string {
35
+ return sha256(secret);
36
+ }
37
+
38
+ /**
39
+ * Compare in constant time.
40
+ *
41
+ * A `===` on a secret leaks its prefix through timing, one byte at a time,
42
+ * which is enough to reconstruct it remotely given patience.
43
+ */
44
+ export function secretMatches(candidate: string, stored: string): boolean {
45
+ const a = Buffer.from(hashSecret(candidate));
46
+ const b = Buffer.from(stored);
47
+ // Different lengths cannot be compared in constant time by
48
+ // `timingSafeEqual`, and a length mismatch is not a secret anyway.
49
+ if (a.length !== b.length) return false;
50
+ return timingSafeEqual(a, b);
51
+ }
52
+
53
+ /** The same comparison for two already-hashed values. */
54
+ export function hashMatches(a: string, b: string): boolean {
55
+ const left = Buffer.from(a);
56
+ const right = Buffer.from(b);
57
+ if (left.length !== right.length) return false;
58
+ return timingSafeEqual(left, right);
59
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Two kinds of failure, and they are not interchangeable.
3
+ *
4
+ * A PROTOCOL error is part of the OAuth response: the spec names the string
5
+ * (`invalid_grant`, `invalid_client`…), the client reads it, and leaking no
6
+ * more than that string is deliberate — "which of the four checks failed" is
7
+ * information an attacker uses to enumerate.
8
+ *
9
+ * A FRAMEWORK error is a mistake in the application: a store that does not
10
+ * answer, a client registered without a redirect URI. Those carry a
11
+ * `E_VISA_*` code and a message meant for a developer, never for a response
12
+ * body.
13
+ */
14
+
15
+ /** Error codes the authorization endpoint may return (OAuth 2.1 §4.1.2.1). */
16
+ export type AuthorizationErrorCode =
17
+ | "invalid_request"
18
+ | "unauthorized_client"
19
+ | "access_denied"
20
+ | "unsupported_response_type"
21
+ | "invalid_scope"
22
+ | "server_error"
23
+ | "temporarily_unavailable";
24
+
25
+ /** Error codes the token endpoint may return (OAuth 2.1 §4.1.4). */
26
+ export type TokenErrorCode =
27
+ | "invalid_request"
28
+ | "invalid_client"
29
+ | "invalid_grant"
30
+ | "unauthorized_client"
31
+ | "unsupported_grant_type"
32
+ | "invalid_scope";
33
+
34
+ export type ProtocolErrorCode = AuthorizationErrorCode | TokenErrorCode;
35
+
36
+ /**
37
+ * A failure the client is told about, in the words the spec chose.
38
+ *
39
+ * `description` is optional and OPTIONAL on purpose: it goes on the wire, so
40
+ * it says what the client can act on and never why a check failed internally.
41
+ */
42
+ export class OAuthError extends Error {
43
+ readonly code: ProtocolErrorCode;
44
+ readonly description: string | undefined;
45
+ /** 401 for `invalid_client`, 400 for everything else (§4.1.4). */
46
+ readonly status: number;
47
+
48
+ constructor(
49
+ code: ProtocolErrorCode,
50
+ description?: string,
51
+ options: { status?: number } = {},
52
+ ) {
53
+ super(description === undefined ? code : `${code}: ${description}`);
54
+ this.name = "OAuthError";
55
+ this.code = code;
56
+ this.description = description;
57
+ this.status = options.status ?? (code === "invalid_client" ? 401 : 400);
58
+ }
59
+
60
+ /** The body, exactly as §4.1.4 shapes it. */
61
+ toResponse(): { error: ProtocolErrorCode; error_description?: string } {
62
+ return this.description === undefined
63
+ ? { error: this.code }
64
+ : { error: this.code, error_description: this.description };
65
+ }
66
+ }
67
+
68
+ /** A mistake in the application, addressed to whoever wired it. */
69
+ export class VisaError extends Error {
70
+ readonly code: string;
71
+ readonly hint: string | undefined;
72
+
73
+ constructor(code: string, message: string, options: { hint?: string } = {}) {
74
+ super(message);
75
+ this.name = "VisaError";
76
+ this.code = code;
77
+ this.hint = options.hint;
78
+ }
79
+ }