@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.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/VisaManager.d.ts +94 -0
- package/dist/VisaManager.d.ts.map +1 -0
- package/dist/VisaManager.js +216 -0
- package/dist/VisaManager.js.map +1 -0
- package/dist/VisaProvider.d.ts +41 -0
- package/dist/VisaProvider.d.ts.map +1 -0
- package/dist/VisaProvider.js +153 -0
- package/dist/VisaProvider.js.map +1 -0
- package/dist/augmentations.d.ts +25 -0
- package/dist/augmentations.d.ts.map +1 -0
- package/dist/augmentations.js +19 -0
- package/dist/augmentations.js.map +1 -0
- package/dist/authorize.d.ts +78 -0
- package/dist/authorize.d.ts.map +1 -0
- package/dist/authorize.js +149 -0
- package/dist/authorize.js.map +1 -0
- package/dist/clients.d.ts +70 -0
- package/dist/clients.d.ts.map +1 -0
- package/dist/clients.js +177 -0
- package/dist/clients.js.map +1 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -0
- package/dist/configure.d.ts +28 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +24 -0
- package/dist/configure.js.map +1 -0
- package/dist/crypto.d.ts +35 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +56 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +50 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/introspect.d.ts +32 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.js +110 -0
- package/dist/introspect.js.map +1 -0
- package/dist/services/main.d.ts +29 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +48 -0
- package/dist/services/main.js.map +1 -0
- package/dist/store.d.ts +44 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +12 -0
- package/dist/store.js.map +1 -0
- package/dist/stores/memory.d.ts +34 -0
- package/dist/stores/memory.d.ts.map +1 -0
- package/dist/stores/memory.js +100 -0
- package/dist/stores/memory.js.map +1 -0
- package/dist/stubs.d.ts +14 -0
- package/dist/stubs.d.ts.map +1 -0
- package/dist/stubs.js +15 -0
- package/dist/stubs.js.map +1 -0
- package/dist/testing/main.d.ts +49 -0
- package/dist/testing/main.d.ts.map +1 -0
- package/dist/testing/main.js +77 -0
- package/dist/testing/main.js.map +1 -0
- package/dist/token.d.ts +20 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +212 -0
- package/dist/token.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +83 -0
- package/src/VisaManager.ts +346 -0
- package/src/VisaProvider.ts +197 -0
- package/src/augmentations.ts +29 -0
- package/src/authorize.ts +250 -0
- package/src/clients.ts +231 -0
- package/src/config.ts +34 -0
- package/src/configure.ts +42 -0
- package/src/crypto.ts +59 -0
- package/src/errors.ts +79 -0
- package/src/index.ts +67 -0
- package/src/introspect.ts +139 -0
- package/src/services/main.ts +57 -0
- package/src/store.ts +56 -0
- package/src/stores/memory.ts +118 -0
- package/src/stubs.ts +16 -0
- package/src/testing/main.ts +119 -0
- package/src/token.ts +325 -0
- package/src/types.ts +134 -0
- package/stubs/config/visa.stub +25 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visa — an OAuth 2.1 authorization server.
|
|
3
|
+
*
|
|
4
|
+
* It issues tokens to OTHER applications on a user's behalf. Knowing who that
|
|
5
|
+
* user is stays warden's job; visa never authenticates anyone, it only asks
|
|
6
|
+
* the application who is signed in.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here depends on a transport or on a database: the manager takes
|
|
9
|
+
* values and returns values, the store is a contract. The HTTP endpoints are
|
|
10
|
+
* in the provider.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import "./augmentations.js";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
type AuthorizationRequest,
|
|
17
|
+
type AuthorizeOptions,
|
|
18
|
+
errorRedirect,
|
|
19
|
+
issueAuthorizationCode,
|
|
20
|
+
successRedirect,
|
|
21
|
+
UnredirectableError,
|
|
22
|
+
type ValidatedRequest,
|
|
23
|
+
validateAuthorizationRequest,
|
|
24
|
+
} from "./authorize.js";
|
|
25
|
+
export {
|
|
26
|
+
assertGrantAllowed,
|
|
27
|
+
authenticateClient,
|
|
28
|
+
type ClientCredentials,
|
|
29
|
+
readCredentials,
|
|
30
|
+
redirectUriMatches,
|
|
31
|
+
resolveScopes,
|
|
32
|
+
} from "./clients.js";
|
|
33
|
+
export { defineConfig, type VisaConfigInput } from "./config.js";
|
|
34
|
+
export { hashSecret, randomToken, secretMatches, sha256 } from "./crypto.js";
|
|
35
|
+
export {
|
|
36
|
+
type AuthorizationErrorCode,
|
|
37
|
+
OAuthError,
|
|
38
|
+
type ProtocolErrorCode,
|
|
39
|
+
type TokenErrorCode,
|
|
40
|
+
VisaError,
|
|
41
|
+
} from "./errors.js";
|
|
42
|
+
export { introspect, revoke, verifyAccessToken } from "./introspect.js";
|
|
43
|
+
export {
|
|
44
|
+
clearVisa,
|
|
45
|
+
default as visa,
|
|
46
|
+
getVisa,
|
|
47
|
+
setVisa,
|
|
48
|
+
} from "./services/main.js";
|
|
49
|
+
export type { VisaStore } from "./store.js";
|
|
50
|
+
export { MemoryStore } from "./stores/memory.js";
|
|
51
|
+
export { type TokenOptions, type TokenRequest, token } from "./token.js";
|
|
52
|
+
export type {
|
|
53
|
+
AccessToken,
|
|
54
|
+
AuthorizationCode,
|
|
55
|
+
Client,
|
|
56
|
+
ClientAuthMethod,
|
|
57
|
+
Consent,
|
|
58
|
+
GrantType,
|
|
59
|
+
IntrospectionResponse,
|
|
60
|
+
RefreshToken,
|
|
61
|
+
TokenResponse,
|
|
62
|
+
} from "./types.js";
|
|
63
|
+
export {
|
|
64
|
+
type AuthorizationOutcome,
|
|
65
|
+
type VisaConfig,
|
|
66
|
+
VisaManager,
|
|
67
|
+
} from "./VisaManager.js";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revocation (RFC 7009) and introspection (RFC 7662).
|
|
3
|
+
*
|
|
4
|
+
* Both answer the same way to a token that is unknown, expired, revoked or
|
|
5
|
+
* belongs to someone else — revocation with 200, introspection with
|
|
6
|
+
* `{ active: false }`. That uniformity IS the security property: a different
|
|
7
|
+
* answer per case turns either endpoint into an oracle for guessing tokens.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { authenticateClient, type ClientCredentials } from "./clients.js";
|
|
11
|
+
import { hashSecret } from "./crypto.js";
|
|
12
|
+
import { OAuthError } from "./errors.js";
|
|
13
|
+
import type { VisaStore } from "./store.js";
|
|
14
|
+
import type { IntrospectionResponse } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/** `{ active: false }` — the only thing an inactive token ever reveals. */
|
|
17
|
+
const INACTIVE: IntrospectionResponse = { active: false };
|
|
18
|
+
|
|
19
|
+
export async function introspect(
|
|
20
|
+
request: Record<string, unknown>,
|
|
21
|
+
credentials: ClientCredentials,
|
|
22
|
+
store: VisaStore,
|
|
23
|
+
now: Date = new Date(),
|
|
24
|
+
issuer?: string,
|
|
25
|
+
): Promise<IntrospectionResponse> {
|
|
26
|
+
// The CALLER authenticates — introspection tells you whether a credential
|
|
27
|
+
// is live, which is exactly what an attacker wants to ask.
|
|
28
|
+
const caller = await authenticateClient(credentials, (id) =>
|
|
29
|
+
store.findClient(id),
|
|
30
|
+
);
|
|
31
|
+
const presented = asString(request.token);
|
|
32
|
+
if (presented === undefined) {
|
|
33
|
+
throw new OAuthError("invalid_request", "token is required.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hash = hashSecret(presented);
|
|
37
|
+
const hint =
|
|
38
|
+
asString(request.token_hint) ?? asString(request.token_type_hint);
|
|
39
|
+
|
|
40
|
+
// The hint is an optimisation, never a filter: a wrong hint must still
|
|
41
|
+
// find the token (RFC 7662 §2.1).
|
|
42
|
+
const access =
|
|
43
|
+
hint === "refresh_token" ? null : await store.findAccessToken(hash);
|
|
44
|
+
if (access !== null) {
|
|
45
|
+
if (access.revokedAt !== undefined || access.expiresAt <= now) {
|
|
46
|
+
return INACTIVE;
|
|
47
|
+
}
|
|
48
|
+
// A client may only introspect its own tokens.
|
|
49
|
+
if (access.clientId !== caller.id) return INACTIVE;
|
|
50
|
+
return {
|
|
51
|
+
active: true,
|
|
52
|
+
...(issuer === undefined ? {} : { iss: issuer }),
|
|
53
|
+
scope: access.scopes.join(" "),
|
|
54
|
+
client_id: access.clientId,
|
|
55
|
+
token_type: "Bearer",
|
|
56
|
+
exp: Math.floor(access.expiresAt.getTime() / 1000),
|
|
57
|
+
...(access.userId === undefined ? {} : { sub: access.userId }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const refresh = await store.findRefreshToken(hash);
|
|
62
|
+
if (refresh === null) return INACTIVE;
|
|
63
|
+
if (
|
|
64
|
+
refresh.revokedAt !== undefined ||
|
|
65
|
+
refresh.consumedAt !== undefined ||
|
|
66
|
+
refresh.expiresAt <= now ||
|
|
67
|
+
refresh.clientId !== caller.id
|
|
68
|
+
) {
|
|
69
|
+
return INACTIVE;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
active: true,
|
|
73
|
+
...(issuer === undefined ? {} : { iss: issuer }),
|
|
74
|
+
scope: refresh.scopes.join(" "),
|
|
75
|
+
client_id: refresh.clientId,
|
|
76
|
+
exp: Math.floor(refresh.expiresAt.getTime() / 1000),
|
|
77
|
+
...(refresh.userId === undefined ? {} : { sub: refresh.userId }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Revoke a token. Answers nothing, always successfully (RFC 7009 §2.2).
|
|
83
|
+
*
|
|
84
|
+
* Revoking a REFRESH token takes its whole family with it, access tokens
|
|
85
|
+
* included: "sign me out" has to mean the session is over, not that one of
|
|
86
|
+
* its two credentials was retired.
|
|
87
|
+
*/
|
|
88
|
+
export async function revoke(
|
|
89
|
+
request: Record<string, unknown>,
|
|
90
|
+
credentials: ClientCredentials,
|
|
91
|
+
store: VisaStore,
|
|
92
|
+
now: Date = new Date(),
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const caller = await authenticateClient(credentials, (id) =>
|
|
95
|
+
store.findClient(id),
|
|
96
|
+
);
|
|
97
|
+
const presented = asString(request.token);
|
|
98
|
+
if (presented === undefined) {
|
|
99
|
+
throw new OAuthError("invalid_request", "token is required.");
|
|
100
|
+
}
|
|
101
|
+
const hash = hashSecret(presented);
|
|
102
|
+
|
|
103
|
+
const refresh = await store.findRefreshToken(hash);
|
|
104
|
+
if (refresh !== null) {
|
|
105
|
+
// Someone else's token: nothing happens, and they are not told so.
|
|
106
|
+
if (refresh.clientId !== caller.id) return;
|
|
107
|
+
await store.revokeFamily(refresh.familyId, now);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const access = await store.findAccessToken(hash);
|
|
112
|
+
if (access === null || access.clientId !== caller.id) return;
|
|
113
|
+
await store.revokeAccessToken(hash, now);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Is this bearer token good, and for what?
|
|
118
|
+
*
|
|
119
|
+
* What a resource server calls on every request — so it reads the store
|
|
120
|
+
* directly instead of going through introspection's client authentication.
|
|
121
|
+
*/
|
|
122
|
+
export async function verifyAccessToken(
|
|
123
|
+
presented: string,
|
|
124
|
+
store: VisaStore,
|
|
125
|
+
now: Date = new Date(),
|
|
126
|
+
): Promise<{ clientId: string; userId?: string; scopes: string[] } | null> {
|
|
127
|
+
const token = await store.findAccessToken(hashSecret(presented));
|
|
128
|
+
if (token === null) return null;
|
|
129
|
+
if (token.revokedAt !== undefined || token.expiresAt <= now) return null;
|
|
130
|
+
return {
|
|
131
|
+
clientId: token.clientId,
|
|
132
|
+
...(token.userId === undefined ? {} : { userId: token.userId }),
|
|
133
|
+
scopes: token.scopes,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function asString(value: unknown): string | undefined {
|
|
138
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
139
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container service accessor — `import visa from '@c9up/visa/services/main'`.
|
|
3
|
+
*
|
|
4
|
+
* Populated by `VisaProvider.boot()`. Reading it before the provider booted
|
|
5
|
+
* throws rather than answering with a half-built server: an authorization
|
|
6
|
+
* server with no store would mint tokens nobody can verify.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { VisaManager } from "../VisaManager.js";
|
|
10
|
+
|
|
11
|
+
let instance: VisaManager | undefined;
|
|
12
|
+
|
|
13
|
+
/** @internal Called by the provider once the manager exists. */
|
|
14
|
+
export function setVisa(manager: VisaManager): void {
|
|
15
|
+
instance = manager;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @internal Read the seated manager, if there is one. */
|
|
19
|
+
export function getVisa(): VisaManager | undefined {
|
|
20
|
+
return instance;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @internal Forget the manager — the provider on shutdown, tests between cases.
|
|
25
|
+
*
|
|
26
|
+
* The caller checks ownership first (`getVisa() === mine`): two applications
|
|
27
|
+
* share this module in one process, and the one shutting down must not clear a
|
|
28
|
+
* manager the other has since seated.
|
|
29
|
+
*/
|
|
30
|
+
export function clearVisa(): void {
|
|
31
|
+
instance = undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolve(): VisaManager {
|
|
35
|
+
if (!instance) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"[visa] accessed before initialization — register VisaProvider, or call setVisa() yourself.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return instance;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A proxy so the import can be held before the provider boots.
|
|
45
|
+
*
|
|
46
|
+
* Symbols and `then` answer undefined: a module namespace is probed for `then`
|
|
47
|
+
* when it is imported, and a proxy that threw there would crash the import.
|
|
48
|
+
*/
|
|
49
|
+
const visa = new Proxy({} as VisaManager, {
|
|
50
|
+
get(_target, property) {
|
|
51
|
+
if (typeof property === "symbol" || property === "then") return undefined;
|
|
52
|
+
const value = Reflect.get(resolve(), property);
|
|
53
|
+
return typeof value === "function" ? value.bind(resolve()) : value;
|
|
54
|
+
},
|
|
55
|
+
}) as VisaManager;
|
|
56
|
+
|
|
57
|
+
export default visa;
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the server needs from wherever its state lives.
|
|
3
|
+
*
|
|
4
|
+
* A contract rather than a table, the same way echo, eclipse and bay do it:
|
|
5
|
+
* an application already running atlas gets a driver, one running on Redis or
|
|
6
|
+
* on a schema it does not own writes twelve methods.
|
|
7
|
+
*
|
|
8
|
+
* Every method that takes a credential takes it HASHED. The plaintext never
|
|
9
|
+
* reaches the store, so a database dump is not a set of working tokens.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
AccessToken,
|
|
14
|
+
AuthorizationCode,
|
|
15
|
+
Client,
|
|
16
|
+
Consent,
|
|
17
|
+
RefreshToken,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
|
|
20
|
+
export interface VisaStore {
|
|
21
|
+
findClient(id: string): Promise<Client | null>;
|
|
22
|
+
|
|
23
|
+
saveAuthorizationCode(code: AuthorizationCode): Promise<void>;
|
|
24
|
+
findAuthorizationCode(codeHash: string): Promise<AuthorizationCode | null>;
|
|
25
|
+
/**
|
|
26
|
+
* Mark a code as used. MUST be atomic against a concurrent exchange: two
|
|
27
|
+
* requests racing with the same code must not both succeed, which is the
|
|
28
|
+
* whole of the single-use guarantee (§7.5.1).
|
|
29
|
+
*
|
|
30
|
+
* Answers `false` when it was already consumed.
|
|
31
|
+
*/
|
|
32
|
+
consumeAuthorizationCode(codeHash: string, at: Date): Promise<boolean>;
|
|
33
|
+
|
|
34
|
+
saveAccessToken(token: AccessToken): Promise<void>;
|
|
35
|
+
findAccessToken(tokenHash: string): Promise<AccessToken | null>;
|
|
36
|
+
revokeAccessToken(tokenHash: string, at: Date): Promise<void>;
|
|
37
|
+
|
|
38
|
+
saveRefreshToken(token: RefreshToken): Promise<void>;
|
|
39
|
+
findRefreshToken(tokenHash: string): Promise<RefreshToken | null>;
|
|
40
|
+
/** Same atomicity requirement as a code: rotation depends on it. */
|
|
41
|
+
consumeRefreshToken(tokenHash: string, at: Date): Promise<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* Revoke every token of a family, refresh and access alike.
|
|
44
|
+
*
|
|
45
|
+
* Called when a consumed refresh token comes back: that is a leak, and the
|
|
46
|
+
* only safe answer is to end the session it belongs to rather than guess
|
|
47
|
+
* which of the two holders is the thief.
|
|
48
|
+
*/
|
|
49
|
+
revokeFamily(familyId: string, at: Date): Promise<void>;
|
|
50
|
+
|
|
51
|
+
findConsent(userId: string, clientId: string): Promise<Consent | null>;
|
|
52
|
+
saveConsent(consent: Consent): Promise<void>;
|
|
53
|
+
|
|
54
|
+
/** Drop what has expired. Optional: a store with a TTL does it itself. */
|
|
55
|
+
prune?(now: Date): Promise<void>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store that needs no database.
|
|
3
|
+
*
|
|
4
|
+
* For tests and for a single-process development server — never for
|
|
5
|
+
* production, where a restart would sign every user out and a second instance
|
|
6
|
+
* would not see the first one's tokens.
|
|
7
|
+
*
|
|
8
|
+
* It is also the reference implementation of the contract: the atomicity the
|
|
9
|
+
* interface demands is trivially true here, in one event loop, which is
|
|
10
|
+
* exactly why a driver on a real database has to say how it achieves it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { VisaStore } from "../store.js";
|
|
14
|
+
import type {
|
|
15
|
+
AccessToken,
|
|
16
|
+
AuthorizationCode,
|
|
17
|
+
Client,
|
|
18
|
+
Consent,
|
|
19
|
+
RefreshToken,
|
|
20
|
+
} from "../types.js";
|
|
21
|
+
|
|
22
|
+
export class MemoryStore implements VisaStore {
|
|
23
|
+
readonly #clients = new Map<string, Client>();
|
|
24
|
+
readonly #codes = new Map<string, AuthorizationCode>();
|
|
25
|
+
readonly #accessTokens = new Map<string, AccessToken>();
|
|
26
|
+
readonly #refreshTokens = new Map<string, RefreshToken>();
|
|
27
|
+
readonly #consents = new Map<string, Consent>();
|
|
28
|
+
|
|
29
|
+
constructor(clients: readonly Client[] = []) {
|
|
30
|
+
for (const client of clients) this.#clients.set(client.id, client);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Register a client. Returns it, so a caller can keep the reference. */
|
|
34
|
+
addClient(client: Client): Client {
|
|
35
|
+
this.#clients.set(client.id, client);
|
|
36
|
+
return client;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async findClient(id: string): Promise<Client | null> {
|
|
40
|
+
return this.#clients.get(id) ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async saveAuthorizationCode(code: AuthorizationCode): Promise<void> {
|
|
44
|
+
this.#codes.set(code.codeHash, code);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async findAuthorizationCode(
|
|
48
|
+
codeHash: string,
|
|
49
|
+
): Promise<AuthorizationCode | null> {
|
|
50
|
+
return this.#codes.get(codeHash) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async consumeAuthorizationCode(codeHash: string, at: Date): Promise<boolean> {
|
|
54
|
+
const code = this.#codes.get(codeHash);
|
|
55
|
+
if (code === undefined || code.consumedAt !== undefined) return false;
|
|
56
|
+
code.consumedAt = at;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async saveAccessToken(token: AccessToken): Promise<void> {
|
|
61
|
+
this.#accessTokens.set(token.tokenHash, token);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async findAccessToken(tokenHash: string): Promise<AccessToken | null> {
|
|
65
|
+
return this.#accessTokens.get(tokenHash) ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async revokeAccessToken(tokenHash: string, at: Date): Promise<void> {
|
|
69
|
+
const token = this.#accessTokens.get(tokenHash);
|
|
70
|
+
if (token !== undefined) token.revokedAt = at;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async saveRefreshToken(token: RefreshToken): Promise<void> {
|
|
74
|
+
this.#refreshTokens.set(token.tokenHash, token);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async findRefreshToken(tokenHash: string): Promise<RefreshToken | null> {
|
|
78
|
+
return this.#refreshTokens.get(tokenHash) ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async consumeRefreshToken(tokenHash: string, at: Date): Promise<boolean> {
|
|
82
|
+
const token = this.#refreshTokens.get(tokenHash);
|
|
83
|
+
if (token === undefined || token.consumedAt !== undefined) return false;
|
|
84
|
+
token.consumedAt = at;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async revokeFamily(familyId: string, at: Date): Promise<void> {
|
|
89
|
+
// Both kinds: a leaked refresh token has usually already bought an
|
|
90
|
+
// access token, and leaving that one alive defeats the revocation.
|
|
91
|
+
for (const token of this.#refreshTokens.values()) {
|
|
92
|
+
if (token.familyId === familyId) token.revokedAt = at;
|
|
93
|
+
}
|
|
94
|
+
for (const token of this.#accessTokens.values()) {
|
|
95
|
+
if (token.familyId === familyId) token.revokedAt = at;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async findConsent(userId: string, clientId: string): Promise<Consent | null> {
|
|
100
|
+
return this.#consents.get(`${userId}\u0000${clientId}`) ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async saveConsent(consent: Consent): Promise<void> {
|
|
104
|
+
this.#consents.set(`${consent.userId}\u0000${consent.clientId}`, consent);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async prune(now: Date): Promise<void> {
|
|
108
|
+
for (const [key, code] of this.#codes) {
|
|
109
|
+
if (code.expiresAt <= now) this.#codes.delete(key);
|
|
110
|
+
}
|
|
111
|
+
for (const [key, token] of this.#accessTokens) {
|
|
112
|
+
if (token.expiresAt <= now) this.#accessTokens.delete(key);
|
|
113
|
+
}
|
|
114
|
+
for (const [key, token] of this.#refreshTokens) {
|
|
115
|
+
if (token.expiresAt <= now) this.#refreshTokens.delete(key);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/stubs.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this package's stubs live.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, as upstream keeps it: a stub root is a path a package
|
|
5
|
+
* publishes, and a caller importing it should not have to import the
|
|
6
|
+
* configure hook to get it.
|
|
7
|
+
*
|
|
8
|
+
* `import.meta.dirname` rather than a URL — upstream reads it straight off
|
|
9
|
+
* `import.meta`, and deriving it from `import.meta.url` breaks wherever that
|
|
10
|
+
* is not a `file://` URL, which is what a test runner serving modules over
|
|
11
|
+
* HTTP hands you.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const stubsRoot = join(import.meta.dirname, "..", "stubs");
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helpers — `import { testVisa } from '@c9up/visa/testing'`.
|
|
3
|
+
*
|
|
4
|
+
* Not a fake of the server: the real one, on a memory store. An authorization
|
|
5
|
+
* server whose test double is lenient teaches applications to ship the leniency
|
|
6
|
+
* — a consent screen tested against a stub that always grants is a consent
|
|
7
|
+
* screen nobody has ever seen refuse.
|
|
8
|
+
*
|
|
9
|
+
* What it removes is the setup, not the rules: a registered client, a PKCE
|
|
10
|
+
* pair, and one call that walks authorize → consent → code → token.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { randomToken, sha256 } from "../crypto.js";
|
|
14
|
+
import { MemoryStore } from "../stores/memory.js";
|
|
15
|
+
import type { Client, GrantType, TokenResponse } from "../types.js";
|
|
16
|
+
import { type VisaConfig, VisaManager } from "../VisaManager.js";
|
|
17
|
+
|
|
18
|
+
export interface TestVisa {
|
|
19
|
+
visa: VisaManager;
|
|
20
|
+
store: MemoryStore;
|
|
21
|
+
/** The client that was registered for you. */
|
|
22
|
+
client: Client;
|
|
23
|
+
/** Its secret, in the clear — it exists only in this process. */
|
|
24
|
+
secret: string;
|
|
25
|
+
/** Credentials shaped the way the endpoints take them. */
|
|
26
|
+
credentials: { clientId: string; clientSecret?: string };
|
|
27
|
+
/** A fresh PKCE pair. */
|
|
28
|
+
pkce(): { verifier: string; challenge: string };
|
|
29
|
+
/**
|
|
30
|
+
* Everything at once: authorize as `userId`, consent, exchange the code.
|
|
31
|
+
* What a test protecting a resource needs, without the four steps.
|
|
32
|
+
*/
|
|
33
|
+
tokensFor(userId: string, scope?: string): Promise<TokenResponse>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TestVisaOptions {
|
|
37
|
+
issuer?: string;
|
|
38
|
+
clientId?: string;
|
|
39
|
+
redirectUri?: string;
|
|
40
|
+
scopes?: string[];
|
|
41
|
+
grantTypes?: GrantType[];
|
|
42
|
+
/** A public client: no secret, and no client_credentials. */
|
|
43
|
+
publicClient?: boolean;
|
|
44
|
+
config?: Partial<Omit<VisaConfig, "issuer" | "store">>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function testVisa(
|
|
48
|
+
options: TestVisaOptions = {},
|
|
49
|
+
): Promise<TestVisa> {
|
|
50
|
+
const redirectUri = options.redirectUri ?? "https://app.test/callback";
|
|
51
|
+
const store = new MemoryStore();
|
|
52
|
+
const visa = new VisaManager({
|
|
53
|
+
issuer: options.issuer ?? "https://auth.test",
|
|
54
|
+
store,
|
|
55
|
+
...options.config,
|
|
56
|
+
});
|
|
57
|
+
const { client, secret } = await visa.registerClient({
|
|
58
|
+
id: options.clientId ?? "test-client",
|
|
59
|
+
name: "Test client",
|
|
60
|
+
redirectUris: [redirectUri],
|
|
61
|
+
scopes: options.scopes ?? ["profile", "email"],
|
|
62
|
+
grantTypes: options.grantTypes ?? ["authorization_code", "refresh_token"],
|
|
63
|
+
...(options.publicClient === true
|
|
64
|
+
? { tokenEndpointAuthMethod: "none" as const }
|
|
65
|
+
: {}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const credentials =
|
|
69
|
+
secret === undefined
|
|
70
|
+
? { clientId: client.id }
|
|
71
|
+
: { clientId: client.id, clientSecret: secret };
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
visa,
|
|
75
|
+
store,
|
|
76
|
+
client,
|
|
77
|
+
secret: secret ?? "",
|
|
78
|
+
credentials,
|
|
79
|
+
pkce() {
|
|
80
|
+
const verifier = randomToken();
|
|
81
|
+
return { verifier, challenge: sha256(verifier) };
|
|
82
|
+
},
|
|
83
|
+
async tokensFor(userId: string, scope?: string): Promise<TokenResponse> {
|
|
84
|
+
const verifier = randomToken();
|
|
85
|
+
const outcome = await visa.authorize(
|
|
86
|
+
{
|
|
87
|
+
response_type: "code",
|
|
88
|
+
client_id: client.id,
|
|
89
|
+
redirect_uri: redirectUri,
|
|
90
|
+
code_challenge: sha256(verifier),
|
|
91
|
+
code_challenge_method: "S256",
|
|
92
|
+
...(scope === undefined ? {} : { scope }),
|
|
93
|
+
},
|
|
94
|
+
userId,
|
|
95
|
+
);
|
|
96
|
+
// The real path, consent screen included — a helper that skipped it
|
|
97
|
+
// would let an application forget the screen and still pass.
|
|
98
|
+
const url =
|
|
99
|
+
outcome.type === "consent"
|
|
100
|
+
? await visa.grant(outcome.request, userId)
|
|
101
|
+
: outcome.type === "redirect"
|
|
102
|
+
? outcome.url
|
|
103
|
+
: (() => {
|
|
104
|
+
throw new Error(`authorize failed: ${outcome.error.message}`);
|
|
105
|
+
})();
|
|
106
|
+
const code = new URL(url).searchParams.get("code");
|
|
107
|
+
if (code === null) throw new Error(`no code in ${url}`);
|
|
108
|
+
return visa.token(
|
|
109
|
+
{
|
|
110
|
+
grant_type: "authorization_code",
|
|
111
|
+
code,
|
|
112
|
+
redirect_uri: redirectUri,
|
|
113
|
+
code_verifier: verifier,
|
|
114
|
+
},
|
|
115
|
+
credentials,
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|