@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
package/src/token.ts ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * The token endpoint — the three grants OAuth 2.1 keeps.
3
+ *
4
+ * `password` and `implicit` are gone from the spec and are not implemented
5
+ * here under any option: both hand a credential to a party that should never
6
+ * hold one, and an application that needs them needs a different design.
7
+ */
8
+
9
+ import {
10
+ assertGrantAllowed,
11
+ authenticateClient,
12
+ type ClientCredentials,
13
+ resolveScopes,
14
+ } from "./clients.js";
15
+ import { hashMatches, hashSecret, randomToken, sha256 } from "./crypto.js";
16
+ import { OAuthError } from "./errors.js";
17
+ import type { VisaStore } from "./store.js";
18
+ import type { Client, GrantType, TokenResponse } from "./types.js";
19
+
20
+ export interface TokenOptions {
21
+ accessTokenTtlSeconds?: number;
22
+ refreshTokenTtlSeconds?: number;
23
+ /** Hand out refresh tokens at all. */
24
+ issueRefreshTokens?: boolean;
25
+ }
26
+
27
+ const DEFAULTS = {
28
+ accessTokenTtlSeconds: 3600,
29
+ refreshTokenTtlSeconds: 60 * 60 * 24 * 30,
30
+ issueRefreshTokens: true,
31
+ };
32
+
33
+ /** The body of a token request, untrusted. */
34
+ export type TokenRequest = Record<string, unknown>;
35
+
36
+ export async function token(
37
+ request: TokenRequest,
38
+ credentials: ClientCredentials,
39
+ store: VisaStore,
40
+ options: TokenOptions = {},
41
+ now: Date = new Date(),
42
+ ): Promise<TokenResponse> {
43
+ const grant = asString(request.grant_type);
44
+ if (grant === undefined) {
45
+ throw new OAuthError("invalid_request", "grant_type is required.");
46
+ }
47
+ // Authenticate BEFORE looking at the grant: an unknown grant from an
48
+ // unauthenticated caller must not be distinguishable from an unknown grant
49
+ // from a real client.
50
+ const client = await authenticateClient(credentials, (id) =>
51
+ store.findClient(id),
52
+ );
53
+
54
+ if (!isKnownGrant(grant)) {
55
+ throw new OAuthError(
56
+ "unsupported_grant_type",
57
+ grant === "password" || grant === "implicit"
58
+ ? `The ${grant} grant was removed in OAuth 2.1.`
59
+ : undefined,
60
+ );
61
+ }
62
+ assertGrantAllowed(client, grant);
63
+
64
+ if (grant === "authorization_code") {
65
+ return exchangeCode(request, client, store, options, now);
66
+ }
67
+ if (grant === "refresh_token") {
68
+ return exchangeRefresh(request, client, store, options, now);
69
+ }
70
+ return clientCredentials(request, client, store, options, now);
71
+ }
72
+
73
+ function isKnownGrant(grant: string): grant is GrantType {
74
+ return (
75
+ grant === "authorization_code" ||
76
+ grant === "refresh_token" ||
77
+ grant === "client_credentials"
78
+ );
79
+ }
80
+
81
+ /**
82
+ * The authorization code grant.
83
+ *
84
+ * Four things are verified, and each one has a published attack behind it:
85
+ * the code belongs to THIS client (otherwise a malicious client redeems
86
+ * another's code), the redirect URI is the one it was issued for (code
87
+ * injection), the PKCE verifier hashes to the stored challenge (interception
88
+ * on a native app), and the code has not been used (replay).
89
+ */
90
+ async function exchangeCode(
91
+ request: TokenRequest,
92
+ client: Client,
93
+ store: VisaStore,
94
+ options: TokenOptions,
95
+ now: Date,
96
+ ): Promise<TokenResponse> {
97
+ const code = asString(request.code);
98
+ const verifier = asString(request.code_verifier);
99
+ if (code === undefined) {
100
+ throw new OAuthError("invalid_request", "code is required.");
101
+ }
102
+ if (verifier === undefined) {
103
+ throw new OAuthError("invalid_request", "code_verifier is required.");
104
+ }
105
+
106
+ const record = await store.findAuthorizationCode(hashSecret(code));
107
+ // One sentence for every failure below: "no such code", "expired",
108
+ // "already used" and "wrong client" are indistinguishable to the caller.
109
+ const invalid = new OAuthError("invalid_grant", "The code is not valid.");
110
+ if (record === null || record.clientId !== client.id) throw invalid;
111
+ if (record.expiresAt <= now) throw invalid;
112
+
113
+ if (record.consumedAt !== undefined) {
114
+ // A code coming back a second time means it leaked. Anything it already
115
+ // bought has to go with it — the legitimate exchange has happened, so
116
+ // the tokens in flight are the attacker's or a race.
117
+ await store.revokeFamily(codeFamily(record.codeHash), now);
118
+ throw invalid;
119
+ }
120
+
121
+ const redirectUri = asString(request.redirect_uri);
122
+ // Required whenever it was in the authorization request, which this server
123
+ // always records.
124
+ if (redirectUri !== record.redirectUri) throw invalid;
125
+
126
+ if (
127
+ !verifierMatches(verifier, record.codeChallenge, record.codeChallengeMethod)
128
+ ) {
129
+ throw invalid;
130
+ }
131
+
132
+ // The single-use gate. Atomic in the store, so two requests racing with the
133
+ // same code cannot both pass.
134
+ if (!(await store.consumeAuthorizationCode(record.codeHash, now))) {
135
+ throw invalid;
136
+ }
137
+
138
+ return issue(
139
+ {
140
+ client,
141
+ userId: record.userId,
142
+ scopes: record.scopes,
143
+ familyId: codeFamily(record.codeHash),
144
+ },
145
+ store,
146
+ options,
147
+ now,
148
+ );
149
+ }
150
+
151
+ /**
152
+ * The family a code's tokens belong to.
153
+ *
154
+ * Derived from the code rather than random, so the tokens minted from a code
155
+ * can be revoked when that same code is replayed — at which point the code
156
+ * record is all we have to go on.
157
+ */
158
+ function codeFamily(codeHash: string): string {
159
+ return sha256(`family:${codeHash}`);
160
+ }
161
+
162
+ /** PKCE verification (RFC 7636 §4.6). */
163
+ function verifierMatches(
164
+ verifier: string,
165
+ challenge: string,
166
+ method: "S256" | "plain",
167
+ ): boolean {
168
+ const computed = method === "S256" ? sha256(verifier) : verifier;
169
+ return hashMatches(computed, challenge);
170
+ }
171
+
172
+ /**
173
+ * The refresh grant, with rotation.
174
+ *
175
+ * Every use mints a new refresh token and consumes the old one. A consumed
176
+ * token coming back is a replay — the legitimate holder has already moved on
177
+ * — and since there is no way to tell the thief from the victim, the whole
178
+ * family is revoked and both are signed out (RFC 9700 §4.14.2).
179
+ */
180
+ async function exchangeRefresh(
181
+ request: TokenRequest,
182
+ client: Client,
183
+ store: VisaStore,
184
+ options: TokenOptions,
185
+ now: Date,
186
+ ): Promise<TokenResponse> {
187
+ const presented = asString(request.refresh_token);
188
+ if (presented === undefined) {
189
+ throw new OAuthError("invalid_request", "refresh_token is required.");
190
+ }
191
+ const record = await store.findRefreshToken(hashSecret(presented));
192
+ const invalid = new OAuthError(
193
+ "invalid_grant",
194
+ "The refresh token is not valid.",
195
+ );
196
+ if (record === null || record.clientId !== client.id) throw invalid;
197
+ if (record.revokedAt !== undefined) throw invalid;
198
+
199
+ if (record.consumedAt !== undefined) {
200
+ await store.revokeFamily(record.familyId, now);
201
+ throw invalid;
202
+ }
203
+ if (record.expiresAt <= now) throw invalid;
204
+
205
+ // A refresh MUST NOT widen what was consented to (§4.3.1); it may narrow.
206
+ const requested = asString(request.scope);
207
+ const scopes =
208
+ requested === undefined ? record.scopes : narrow(requested, record.scopes);
209
+
210
+ if (!(await store.consumeRefreshToken(record.tokenHash, now))) throw invalid;
211
+
212
+ return issue(
213
+ {
214
+ client,
215
+ ...(record.userId === undefined ? {} : { userId: record.userId }),
216
+ scopes,
217
+ familyId: record.familyId,
218
+ },
219
+ store,
220
+ options,
221
+ now,
222
+ );
223
+ }
224
+
225
+ function narrow(requested: string, granted: readonly string[]): string[] {
226
+ const asked = requested.split(/\s+/).filter((scope) => scope !== "");
227
+ const extra = asked.filter((scope) => !granted.includes(scope));
228
+ if (extra.length > 0) {
229
+ throw new OAuthError(
230
+ "invalid_scope",
231
+ `Not part of the original grant: ${extra.join(" ")}`,
232
+ );
233
+ }
234
+ return asked;
235
+ }
236
+
237
+ /**
238
+ * The client credentials grant — the client acting as itself.
239
+ *
240
+ * No user, so no refresh token: there is nothing to keep alive on someone's
241
+ * behalf, and the client can always ask again with the credentials it holds
242
+ * (§4.2.3).
243
+ */
244
+ async function clientCredentials(
245
+ request: TokenRequest,
246
+ client: Client,
247
+ store: VisaStore,
248
+ options: TokenOptions,
249
+ now: Date,
250
+ ): Promise<TokenResponse> {
251
+ if (client.tokenEndpointAuthMethod === "none") {
252
+ // A public client has no secret, so "the client itself" is anyone who
253
+ // read its id out of a browser.
254
+ throw new OAuthError(
255
+ "unauthorized_client",
256
+ "A public client cannot use the client_credentials grant.",
257
+ );
258
+ }
259
+ const scopes = resolveScopes(asString(request.scope), client);
260
+ return issue(
261
+ { client, scopes },
262
+ store,
263
+ { ...options, issueRefreshTokens: false },
264
+ now,
265
+ );
266
+ }
267
+
268
+ interface Issued {
269
+ client: Client;
270
+ userId?: string;
271
+ scopes: string[];
272
+ familyId?: string;
273
+ }
274
+
275
+ /** Mint the pair and store both, hashed. */
276
+ async function issue(
277
+ grant: Issued,
278
+ store: VisaStore,
279
+ options: TokenOptions,
280
+ now: Date,
281
+ ): Promise<TokenResponse> {
282
+ const accessTtl =
283
+ options.accessTokenTtlSeconds ?? DEFAULTS.accessTokenTtlSeconds;
284
+ const accessToken = randomToken();
285
+
286
+ await store.saveAccessToken({
287
+ tokenHash: hashSecret(accessToken),
288
+ clientId: grant.client.id,
289
+ ...(grant.userId === undefined ? {} : { userId: grant.userId }),
290
+ scopes: grant.scopes,
291
+ expiresAt: new Date(now.getTime() + accessTtl * 1000),
292
+ ...(grant.familyId === undefined ? {} : { familyId: grant.familyId }),
293
+ });
294
+
295
+ const response: TokenResponse = {
296
+ access_token: accessToken,
297
+ token_type: "Bearer",
298
+ expires_in: accessTtl,
299
+ scope: grant.scopes.join(" "),
300
+ };
301
+
302
+ const wantsRefresh =
303
+ (options.issueRefreshTokens ?? DEFAULTS.issueRefreshTokens) &&
304
+ grant.familyId !== undefined &&
305
+ grant.client.grantTypes.includes("refresh_token");
306
+ if (!wantsRefresh || grant.familyId === undefined) return response;
307
+
308
+ const refreshTtl =
309
+ options.refreshTokenTtlSeconds ?? DEFAULTS.refreshTokenTtlSeconds;
310
+ const refreshToken = randomToken();
311
+ await store.saveRefreshToken({
312
+ tokenHash: hashSecret(refreshToken),
313
+ familyId: grant.familyId,
314
+ clientId: grant.client.id,
315
+ ...(grant.userId === undefined ? {} : { userId: grant.userId }),
316
+ scopes: grant.scopes,
317
+ expiresAt: new Date(now.getTime() + refreshTtl * 1000),
318
+ });
319
+ response.refresh_token = refreshToken;
320
+ return response;
321
+ }
322
+
323
+ function asString(value: unknown): string | undefined {
324
+ return typeof value === "string" && value !== "" ? value : undefined;
325
+ }
package/src/types.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * What an authorization server stores, and what it hands back.
3
+ *
4
+ * Nothing here is an entity of a particular ORM: the store contract takes and
5
+ * returns these shapes, so the same server runs on atlas, on Redis or on a
6
+ * table someone else owns.
7
+ */
8
+
9
+ /** How a client proves who it is at the token endpoint. */
10
+ export type ClientAuthMethod =
11
+ /** A confidential client with a secret, sent as HTTP Basic (§2.4.1). */
12
+ | "client_secret_basic"
13
+ /** The same secret, in the form body. */
14
+ | "client_secret_post"
15
+ /** A public client — a SPA, a native app — which has no secret to keep. */
16
+ | "none";
17
+
18
+ export type GrantType =
19
+ | "authorization_code"
20
+ | "refresh_token"
21
+ | "client_credentials";
22
+
23
+ /**
24
+ * A registered application.
25
+ *
26
+ * `redirectUris` is a list of COMPLETE uris, path included: OAuth 2.1 requires
27
+ * an exact match, because a prefix match is how an open redirect on the
28
+ * client's domain turns into a stolen authorization code.
29
+ */
30
+ export interface Client {
31
+ id: string;
32
+ name: string;
33
+ /** Hashed. The plaintext is shown once, at registration, and never stored. */
34
+ secretHash?: string;
35
+ redirectUris: string[];
36
+ grantTypes: GrantType[];
37
+ /** What this client may ever ask for; a request narrows it, never widens it. */
38
+ scopes: string[];
39
+ tokenEndpointAuthMethod: ClientAuthMethod;
40
+ /** Skip the consent screen — for a first-party application you own. */
41
+ trusted?: boolean;
42
+ }
43
+
44
+ /**
45
+ * An authorization code: single use, short lived, bound to everything that
46
+ * was true when it was issued.
47
+ */
48
+ export interface AuthorizationCode {
49
+ /** Hashed, like every other credential this package persists. */
50
+ codeHash: string;
51
+ clientId: string;
52
+ userId: string;
53
+ redirectUri: string;
54
+ scopes: string[];
55
+ codeChallenge: string;
56
+ codeChallengeMethod: "S256" | "plain";
57
+ expiresAt: Date;
58
+ /** Set the moment it is exchanged, so a replay is detectable (§7.5.1). */
59
+ consumedAt?: Date;
60
+ /** Carried through to the id_token when OIDC lands. */
61
+ nonce?: string;
62
+ }
63
+
64
+ export interface AccessToken {
65
+ tokenHash: string;
66
+ clientId: string;
67
+ /** Absent for `client_credentials`: there is no user behind it. */
68
+ userId?: string;
69
+ scopes: string[];
70
+ expiresAt: Date;
71
+ revokedAt?: Date;
72
+ /**
73
+ * The refresh family this token was minted in, when there is one.
74
+ *
75
+ * Without it, revoking a leaked family would leave the access token it
76
+ * already bought alive for its full lifetime — which is most of what the
77
+ * revocation exists to prevent.
78
+ */
79
+ familyId?: string;
80
+ }
81
+
82
+ /**
83
+ * A refresh token, and the family it belongs to.
84
+ *
85
+ * `familyId` is what makes replay detection possible: rotation issues a new
86
+ * token in the same family, so presenting an old one proves a leak and the
87
+ * whole family goes (RFC 9700 §4.14.2).
88
+ */
89
+ export interface RefreshToken {
90
+ tokenHash: string;
91
+ familyId: string;
92
+ clientId: string;
93
+ userId?: string;
94
+ scopes: string[];
95
+ expiresAt: Date;
96
+ consumedAt?: Date;
97
+ revokedAt?: Date;
98
+ }
99
+
100
+ /** What a user agreed to give a client, so they are asked once. */
101
+ export interface Consent {
102
+ userId: string;
103
+ clientId: string;
104
+ scopes: string[];
105
+ grantedAt: Date;
106
+ }
107
+
108
+ /** The token endpoint's success body (§4.1.4). */
109
+ export interface TokenResponse {
110
+ access_token: string;
111
+ token_type: "Bearer";
112
+ expires_in: number;
113
+ refresh_token?: string;
114
+ scope?: string;
115
+ }
116
+
117
+ /** The introspection body (RFC 7662 §2.2). */
118
+ export interface IntrospectionResponse {
119
+ active: boolean;
120
+ /**
121
+ * Which authorization server vouches for this token.
122
+ *
123
+ * An opaque token carries nothing by itself; the issuer is a property of
124
+ * the SERVER, and this is where a caller gets to see it.
125
+ */
126
+ iss?: string;
127
+ scope?: string;
128
+ client_id?: string;
129
+ username?: string;
130
+ sub?: string;
131
+ exp?: number;
132
+ iat?: number;
133
+ token_type?: "Bearer";
134
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ to: config/visa.ts
3
+ ---
4
+ import { defineConfig } from '@c9up/visa/config'
5
+ import { MemoryStore } from '@c9up/visa/stores/memory'
6
+ import env from '#start/env'
7
+
8
+ export default defineConfig({
9
+ /**
10
+ * The public URL this server answers on. It is the issuer every token is
11
+ * bound to, so a client that validates it rejects anything minted elsewhere.
12
+ */
13
+ issuer: env.get('VISA_ISSUER'),
14
+
15
+ /**
16
+ * REPLACE THIS before deploying. MemoryStore keeps codes, tokens and
17
+ * consents in one process: a restart signs every user out, and a second
18
+ * instance sees none of the first one's tokens.
19
+ */
20
+ store: new MemoryStore(),
21
+
22
+ // An hour is long enough to be useful, short enough that a leaked token is
23
+ // not a standing invitation.
24
+ accessTokenTtlSeconds: 3600,
25
+ })