@c9up/visa 0.1.0 → 0.1.2
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/dist/VisaManager.d.ts +40 -7
- package/dist/VisaManager.d.ts.map +1 -1
- package/dist/VisaManager.js +79 -3
- package/dist/VisaManager.js.map +1 -1
- package/dist/VisaProvider.d.ts.map +1 -1
- package/dist/VisaProvider.js +56 -0
- package/dist/VisaProvider.js.map +1 -1
- package/dist/authorize.d.ts +18 -0
- package/dist/authorize.d.ts.map +1 -1
- package/dist/authorize.js +6 -0
- package/dist/authorize.js.map +1 -1
- package/dist/config.d.ts +35 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +19 -0
- package/dist/configure.js.map +1 -1
- package/dist/errors.d.ts +9 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/guard.d.ts +243 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/guard.js +293 -0
- package/dist/guard.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/introspect.d.ts +11 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +27 -1
- package/dist/introspect.js.map +1 -1
- package/dist/protectedResource.d.ts +81 -0
- package/dist/protectedResource.d.ts.map +1 -0
- package/dist/protectedResource.js +124 -0
- package/dist/protectedResource.js.map +1 -0
- package/dist/register.d.ts +96 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +173 -0
- package/dist/register.js.map +1 -0
- package/dist/resource.d.ts +50 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +101 -0
- package/dist/resource.js.map +1 -0
- package/dist/store.d.ts +33 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/stores/atlas.d.ts +106 -0
- package/dist/stores/atlas.d.ts.map +1 -0
- package/dist/stores/atlas.js +408 -0
- package/dist/stores/atlas.js.map +1 -0
- package/dist/stores/memory.d.ts +5 -0
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +30 -0
- package/dist/stores/memory.js.map +1 -1
- package/dist/token.d.ts +2 -0
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +21 -1
- package/dist/token.js.map +1 -1
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/VisaManager.ts +109 -3
- package/src/VisaProvider.ts +64 -0
- package/src/authorize.ts +27 -0
- package/src/config.ts +35 -0
- package/src/configure.ts +21 -0
- package/src/errors.ts +16 -2
- package/src/guard.ts +443 -0
- package/src/index.ts +41 -0
- package/src/introspect.ts +33 -2
- package/src/protectedResource.ts +171 -0
- package/src/register.ts +296 -0
- package/src/resource.ts +123 -0
- package/src/store.ts +38 -0
- package/src/stores/atlas.ts +514 -0
- package/src/stores/memory.ts +38 -0
- package/src/token.ts +45 -1
- package/src/types.ts +50 -0
- package/stubs/database/migrations/9000_create_visa_tables.stub +112 -0
package/src/authorize.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./clients.js";
|
|
16
16
|
import { hashSecret, randomToken } from "./crypto.js";
|
|
17
17
|
import { OAuthError } from "./errors.js";
|
|
18
|
+
import { readResourceParameter, validateResources } from "./resource.js";
|
|
18
19
|
import type { VisaStore } from "./store.js";
|
|
19
20
|
import type { AuthorizationCode, Client } from "./types.js";
|
|
20
21
|
|
|
@@ -30,6 +31,13 @@ export interface AuthorizationRequest {
|
|
|
30
31
|
nonce?: string;
|
|
31
32
|
/** A client may ask to always see the consent screen. */
|
|
32
33
|
prompt?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Which resource the token is for (RFC 8707). One, or several.
|
|
36
|
+
*
|
|
37
|
+
* A parser hands repeated parameters over as an array, so both shapes are
|
|
38
|
+
* accepted rather than one being silently the only one that works.
|
|
39
|
+
*/
|
|
40
|
+
resource?: string | string[];
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
/** A request that passed every check, with the values it resolved to. */
|
|
@@ -41,6 +49,8 @@ export interface ValidatedRequest {
|
|
|
41
49
|
codeChallenge: string;
|
|
42
50
|
codeChallengeMethod: "S256" | "plain";
|
|
43
51
|
nonce: string | undefined;
|
|
52
|
+
/** The resources the token will be bound to (RFC 8707). Empty means unbound. */
|
|
53
|
+
resources: string[];
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
/**
|
|
@@ -61,6 +71,15 @@ export class UnredirectableError extends Error {
|
|
|
61
71
|
export interface AuthorizeOptions {
|
|
62
72
|
/** Lifetime of the code. Short on purpose — see `issueAuthorizationCode`. */
|
|
63
73
|
codeTtlSeconds?: number;
|
|
74
|
+
/**
|
|
75
|
+
* The resources this server will issue tokens for (RFC 8707).
|
|
76
|
+
*
|
|
77
|
+
* Left out, any well-formed resource is accepted and carried — which is
|
|
78
|
+
* useful while a deployment finds its names, and worth replacing with the
|
|
79
|
+
* list once it has: an unknown resource should be refused with
|
|
80
|
+
* `invalid_target` rather than minted for.
|
|
81
|
+
*/
|
|
82
|
+
resourcesSupported?: readonly string[];
|
|
64
83
|
/**
|
|
65
84
|
* Accept `plain` as a code challenge method.
|
|
66
85
|
*
|
|
@@ -119,6 +138,10 @@ export async function validateAuthorizationRequest(
|
|
|
119
138
|
options,
|
|
120
139
|
);
|
|
121
140
|
const scopes = resolveScopes(request.scope, client);
|
|
141
|
+
const resources = validateResources(
|
|
142
|
+
readResourceParameter(request.resource),
|
|
143
|
+
options.resourcesSupported,
|
|
144
|
+
);
|
|
122
145
|
|
|
123
146
|
return {
|
|
124
147
|
client,
|
|
@@ -128,6 +151,7 @@ export async function validateAuthorizationRequest(
|
|
|
128
151
|
codeChallenge,
|
|
129
152
|
codeChallengeMethod,
|
|
130
153
|
nonce: request.nonce,
|
|
154
|
+
resources,
|
|
131
155
|
};
|
|
132
156
|
}
|
|
133
157
|
|
|
@@ -215,6 +239,9 @@ export async function issueAuthorizationCode(
|
|
|
215
239
|
codeChallengeMethod: validated.codeChallengeMethod,
|
|
216
240
|
expiresAt: new Date(now.getTime() + ttl * 1000),
|
|
217
241
|
...(validated.nonce === undefined ? {} : { nonce: validated.nonce }),
|
|
242
|
+
...(validated.resources.length === 0
|
|
243
|
+
? {}
|
|
244
|
+
: { resources: validated.resources }),
|
|
218
245
|
};
|
|
219
246
|
await store.saveAuthorizationCode(record);
|
|
220
247
|
return code;
|
package/src/config.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* provider reads out of the config store.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { RegistrationOptions } from "./register.js";
|
|
6
7
|
import type { VisaStore } from "./store.js";
|
|
7
8
|
|
|
8
9
|
export interface VisaConfigInput {
|
|
@@ -27,6 +28,40 @@ export interface VisaConfigInput {
|
|
|
27
28
|
allowPlainChallenge?: boolean;
|
|
28
29
|
/** Where the endpoints are mounted. Default `/oauth`. */
|
|
29
30
|
prefix?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The resources this server issues tokens for (RFC 8707).
|
|
33
|
+
*
|
|
34
|
+
* Left out, any well-formed `resource` is accepted and carried. Named, an
|
|
35
|
+
* unknown one is refused with `invalid_target` — which is what stops a
|
|
36
|
+
* client asking for a token aimed at a server you do not run.
|
|
37
|
+
*/
|
|
38
|
+
resourcesSupported?: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Let clients register themselves (RFC 7591).
|
|
41
|
+
*
|
|
42
|
+
* OFF unless you say otherwise. An MCP client such as claude.ai has nobody
|
|
43
|
+
* to fill in a form for it, so it needs this; a server on the public
|
|
44
|
+
* internet with it open lets anyone create a client, so think about
|
|
45
|
+
* `initialAccessToken` before shipping one.
|
|
46
|
+
*/
|
|
47
|
+
registration?: RegistrationOptions;
|
|
48
|
+
/**
|
|
49
|
+
* The protected resource this server issues tokens for (RFC 9728).
|
|
50
|
+
*
|
|
51
|
+
* Declare it and two things follow: the metadata document is served at
|
|
52
|
+
* `/.well-known/oauth-protected-resource`, and a refusal can name it. An
|
|
53
|
+
* MCP client walks from the 401 to that document to this server without
|
|
54
|
+
* anybody pasting a URL into a config file, which is the whole reason the
|
|
55
|
+
* RFC exists.
|
|
56
|
+
*/
|
|
57
|
+
protectedResource?: {
|
|
58
|
+
/** The resource identifier — an absolute URI, no fragment. */
|
|
59
|
+
resource: string;
|
|
60
|
+
/** What a client may ask for. RECOMMENDED by §3.2. */
|
|
61
|
+
scopesSupported?: string[];
|
|
62
|
+
resourceName?: string;
|
|
63
|
+
resourceDocumentation?: string;
|
|
64
|
+
};
|
|
30
65
|
}
|
|
31
66
|
|
|
32
67
|
export function defineConfig(config: VisaConfigInput): VisaConfigInput {
|
package/src/configure.ts
CHANGED
|
@@ -11,8 +11,20 @@
|
|
|
11
11
|
* session on deploy, and a consent screen nobody wrote grants silently.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { access, constants } from "node:fs/promises";
|
|
15
|
+
import { resolve } from "node:path";
|
|
14
16
|
import { stubsRoot } from "./stubs.js";
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The migration this package ships.
|
|
20
|
+
*
|
|
21
|
+
* A high, stable prefix rather than a timestamp: these five tables reference
|
|
22
|
+
* nothing else, so running them last is fine, and a number nobody's own
|
|
23
|
+
* migrations are likely to have taken means the file lands instead of silently
|
|
24
|
+
* losing to a name that already exists.
|
|
25
|
+
*/
|
|
26
|
+
const MIGRATION_STUB = "database/migrations/9000_create_visa_tables.stub";
|
|
27
|
+
|
|
16
28
|
interface Codemods {
|
|
17
29
|
addProvider(importPath: string): Promise<void>;
|
|
18
30
|
addEnvVars(vars: Record<string, string>): Promise<void>;
|
|
@@ -30,6 +42,11 @@ interface Codemods {
|
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
export async function configure(codemods: Codemods): Promise<void> {
|
|
45
|
+
// Fail fast BEFORE touching the project: a pruned tarball or an unreadable
|
|
46
|
+
// stub must not leave an application with a provider registered, a config
|
|
47
|
+
// written and no tables to put anything in.
|
|
48
|
+
await access(resolve(stubsRoot, MIGRATION_STUB), constants.R_OK);
|
|
49
|
+
|
|
33
50
|
// The config below reads this, so it is declared here. Writing the file
|
|
34
51
|
// without it leaves an application whose config asks the environment for
|
|
35
52
|
// something nothing ever put there.
|
|
@@ -39,4 +56,8 @@ export async function configure(codemods: Codemods): Promise<void> {
|
|
|
39
56
|
|
|
40
57
|
await codemods.addProvider("@c9up/visa/provider");
|
|
41
58
|
await codemods.makeUsingStub(stubsRoot, "config/visa.stub");
|
|
59
|
+
// The tables the atlas store reads and writes. An application on another
|
|
60
|
+
// data layer deletes the file and keeps its own store — which is why this
|
|
61
|
+
// is a migration and not a schema the package owns.
|
|
62
|
+
await codemods.makeUsingStub(stubsRoot, MIGRATION_STUB);
|
|
42
63
|
}
|
package/src/errors.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type AuthorizationErrorCode =
|
|
|
19
19
|
| "access_denied"
|
|
20
20
|
| "unsupported_response_type"
|
|
21
21
|
| "invalid_scope"
|
|
22
|
+
/** RFC 8707 §2 — a resource this server will not issue for, or cannot parse. */
|
|
23
|
+
| "invalid_target"
|
|
22
24
|
| "server_error"
|
|
23
25
|
| "temporarily_unavailable";
|
|
24
26
|
|
|
@@ -29,9 +31,21 @@ export type TokenErrorCode =
|
|
|
29
31
|
| "invalid_grant"
|
|
30
32
|
| "unauthorized_client"
|
|
31
33
|
| "unsupported_grant_type"
|
|
32
|
-
| "invalid_scope"
|
|
34
|
+
| "invalid_scope"
|
|
35
|
+
/** RFC 8707 §2. */
|
|
36
|
+
| "invalid_target";
|
|
37
|
+
|
|
38
|
+
/** Error codes the registration endpoint may return (RFC 7591 §3.2.2). */
|
|
39
|
+
export type RegistrationErrorCode =
|
|
40
|
+
| "invalid_redirect_uri"
|
|
41
|
+
| "invalid_client_metadata"
|
|
42
|
+
| "invalid_software_statement"
|
|
43
|
+
| "unapproved_software_statement";
|
|
33
44
|
|
|
34
|
-
export type ProtocolErrorCode =
|
|
45
|
+
export type ProtocolErrorCode =
|
|
46
|
+
| AuthorizationErrorCode
|
|
47
|
+
| TokenErrorCode
|
|
48
|
+
| RegistrationErrorCode;
|
|
35
49
|
|
|
36
50
|
/**
|
|
37
51
|
* A failure the client is told about, in the words the spec chose.
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `visaGuard()` — authenticating a request with an OAuth access token.
|
|
3
|
+
*
|
|
4
|
+
* Declared in `config/auth.ts` beside `jwtGuard` and `sessionGuard`, and it
|
|
5
|
+
* behaves like the access-tokens guard upstream: the authenticated user carries
|
|
6
|
+
* a `currentAccessToken` whose `abilities` are the token's scopes, with
|
|
7
|
+
* `allows`, `denies`, `authorize`, `isExpired()` and `lastUsedAt` answering
|
|
8
|
+
* exactly as they do there. `allows` treats `*` as every ability, which is the
|
|
9
|
+
* upstream rule read off `@adonisjs/auth`'s build rather than recalled.
|
|
10
|
+
*
|
|
11
|
+
* Two things this fixes for every application at once. A route gate no longer
|
|
12
|
+
* has to introspect the token itself — the scopes are on the guard's token. And
|
|
13
|
+
* the user is fetched through the application's own provider on every request,
|
|
14
|
+
* so an account disabled after the token was issued is refused here instead of
|
|
15
|
+
* in each application that remembers to check.
|
|
16
|
+
*
|
|
17
|
+
* NO import of warden. A guard is structurally `{ name, authenticate, verify }`
|
|
18
|
+
* and that is all warden asks for, so visa stays a package you can run without
|
|
19
|
+
* it — the same rule every other package in this universe follows.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { hashSecret, randomToken } from "./crypto.js";
|
|
23
|
+
import { verifyAccessToken } from "./introspect.js";
|
|
24
|
+
import { wwwAuthenticate } from "./protectedResource.js";
|
|
25
|
+
import type { VisaStore } from "./store.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The user shape a guard hands back.
|
|
29
|
+
*
|
|
30
|
+
* `id` and the two optional arrays are what warden reads; everything else the
|
|
31
|
+
* application's provider returns travels with it untouched.
|
|
32
|
+
*/
|
|
33
|
+
export interface GuardUser {
|
|
34
|
+
id: string;
|
|
35
|
+
roles?: string[];
|
|
36
|
+
permissions?: string[];
|
|
37
|
+
/** The token this request was authenticated with. Set by the guard. */
|
|
38
|
+
currentAccessToken?: VisaAccessToken;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the guard needs from the store, and nothing more.
|
|
44
|
+
*
|
|
45
|
+
* A narrow slice rather than the whole contract: authenticating reads one row
|
|
46
|
+
* and records one use, so that is what it asks for. A full {@link VisaStore}
|
|
47
|
+
* satisfies it, and a resource server that keeps only tokens can implement two
|
|
48
|
+
* methods instead of twelve.
|
|
49
|
+
*/
|
|
50
|
+
export type GuardStore = Pick<
|
|
51
|
+
VisaStore,
|
|
52
|
+
| "findAccessToken"
|
|
53
|
+
| "touchAccessToken"
|
|
54
|
+
| "saveAccessToken"
|
|
55
|
+
| "revokeAccessToken"
|
|
56
|
+
>;
|
|
57
|
+
|
|
58
|
+
/** What a guard answers. Warden's `AuthResult`, declared here so visa owes it nothing. */
|
|
59
|
+
export interface GuardResult {
|
|
60
|
+
authenticated: boolean;
|
|
61
|
+
user?: GuardUser;
|
|
62
|
+
error?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What a test client sends to be this user. Warden's `AuthClientResponse`. */
|
|
66
|
+
export interface GuardClientResponse {
|
|
67
|
+
headers?: Record<string, string>;
|
|
68
|
+
session?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The token behind the current request.
|
|
73
|
+
*
|
|
74
|
+
* Upstream's `AccessToken`, minus what an opaque OAuth token has no equivalent
|
|
75
|
+
* for: there is no `identifier`/`secret` pair to decode here, because visa
|
|
76
|
+
* stores a hash and the plaintext never comes back.
|
|
77
|
+
*/
|
|
78
|
+
export class VisaAccessToken {
|
|
79
|
+
/** The client the token was issued to — an OAuth token always has one. */
|
|
80
|
+
readonly clientId: string;
|
|
81
|
+
/** Absent for `client_credentials`: there is no user behind it. */
|
|
82
|
+
readonly userId?: string;
|
|
83
|
+
/** The token's scopes. Named `abilities` for the upstream surface. */
|
|
84
|
+
readonly abilities: string[];
|
|
85
|
+
/**
|
|
86
|
+
* A recognisable name, as upstream carries one.
|
|
87
|
+
*
|
|
88
|
+
* An OAuth token is not named by the user who holds it — it is named by the
|
|
89
|
+
* client it was issued to — so this is the client's name when the store
|
|
90
|
+
* knows it, and null otherwise.
|
|
91
|
+
*/
|
|
92
|
+
readonly name: string | null;
|
|
93
|
+
readonly expiresAt: Date | null;
|
|
94
|
+
readonly lastUsedAt: Date | null;
|
|
95
|
+
/**
|
|
96
|
+
* The token itself, and only just after it was created.
|
|
97
|
+
*
|
|
98
|
+
* Upstream's `value`: the one moment the plaintext exists is when it is
|
|
99
|
+
* minted, because what is stored is a hash. A token read back from the
|
|
100
|
+
* store never has it, and that is not a gap — it is why a database copy is
|
|
101
|
+
* not a set of working tokens.
|
|
102
|
+
*/
|
|
103
|
+
readonly value?: string;
|
|
104
|
+
|
|
105
|
+
constructor(attributes: {
|
|
106
|
+
clientId: string;
|
|
107
|
+
userId?: string;
|
|
108
|
+
abilities: string[];
|
|
109
|
+
name?: string | null;
|
|
110
|
+
expiresAt?: Date | null;
|
|
111
|
+
lastUsedAt?: Date | null;
|
|
112
|
+
value?: string;
|
|
113
|
+
}) {
|
|
114
|
+
this.clientId = attributes.clientId;
|
|
115
|
+
if (attributes.userId !== undefined) this.userId = attributes.userId;
|
|
116
|
+
this.abilities = [...attributes.abilities];
|
|
117
|
+
this.name = attributes.name ?? null;
|
|
118
|
+
this.expiresAt = attributes.expiresAt ?? null;
|
|
119
|
+
this.lastUsedAt = attributes.lastUsedAt ?? null;
|
|
120
|
+
if (attributes.value !== undefined) this.value = attributes.value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `*` means every ability — upstream's rule, read off its build. */
|
|
124
|
+
allows(ability: string): boolean {
|
|
125
|
+
return this.abilities.includes(ability) || this.abilities.includes("*");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
denies(ability: string): boolean {
|
|
129
|
+
return !this.allows(ability);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Throw unless the token carries `ability`.
|
|
134
|
+
*
|
|
135
|
+
* The upstream error id verbatim, so a handler that already maps
|
|
136
|
+
* `E_UNAUTHORIZED_ACCESS` keeps working.
|
|
137
|
+
*/
|
|
138
|
+
authorize(ability: string): void {
|
|
139
|
+
if (this.allows(ability)) return;
|
|
140
|
+
const error = new Error(
|
|
141
|
+
`Access denied: the token does not carry "${ability}"`,
|
|
142
|
+
) as Error & { code: string; status: number };
|
|
143
|
+
error.code = "E_UNAUTHORIZED_ACCESS";
|
|
144
|
+
error.status = 403;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A token with no expiry never expires. */
|
|
149
|
+
isExpired(): boolean {
|
|
150
|
+
if (this.expiresAt === null) return false;
|
|
151
|
+
return this.expiresAt.getTime() <= Date.now();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Upstream's keys, plus the client an OAuth token always belongs to. */
|
|
155
|
+
toJSON(): {
|
|
156
|
+
type: "bearer";
|
|
157
|
+
name: string | null;
|
|
158
|
+
token: string | undefined;
|
|
159
|
+
clientId: string;
|
|
160
|
+
abilities: string[];
|
|
161
|
+
lastUsedAt: Date | null;
|
|
162
|
+
expiresAt: Date | null;
|
|
163
|
+
} {
|
|
164
|
+
return {
|
|
165
|
+
type: "bearer",
|
|
166
|
+
name: this.name,
|
|
167
|
+
// Present exactly once, on the token `createToken` just returned —
|
|
168
|
+
// which is the only time a caller can hand it to anybody.
|
|
169
|
+
token: this.value,
|
|
170
|
+
clientId: this.clientId,
|
|
171
|
+
abilities: this.abilities,
|
|
172
|
+
lastUsedAt: this.lastUsedAt,
|
|
173
|
+
expiresAt: this.expiresAt,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** An hour, as the token endpoint uses by default. */
|
|
179
|
+
const DEFAULT_TTL = 3600;
|
|
180
|
+
|
|
181
|
+
export interface VisaGuardConfig {
|
|
182
|
+
/**
|
|
183
|
+
* Where the tokens live. A function, so the guard can be built in
|
|
184
|
+
* `config/auth.ts` before the provider has seated the manager — config is
|
|
185
|
+
* read at boot, and the store exists a moment later.
|
|
186
|
+
*/
|
|
187
|
+
store: GuardStore | (() => GuardStore);
|
|
188
|
+
/**
|
|
189
|
+
* The application's user provider — upstream's `tokensUserProvider({ model })`.
|
|
190
|
+
*
|
|
191
|
+
* Called on every authenticated request, deliberately: a token issued to an
|
|
192
|
+
* account that has since been disabled must stop working, and the only way
|
|
193
|
+
* to know is to ask. Answer `null` and the request is refused.
|
|
194
|
+
*/
|
|
195
|
+
findUser: (userId: string) => Promise<GuardUser | null>;
|
|
196
|
+
/**
|
|
197
|
+
* What a `client_credentials` token authenticates as, when anything.
|
|
198
|
+
*
|
|
199
|
+
* There is no user behind that grant, so by default such a token does not
|
|
200
|
+
* authenticate a user at all and the guard refuses. An application that
|
|
201
|
+
* serves machine callers supplies this and gets a subject of its choosing.
|
|
202
|
+
*/
|
|
203
|
+
findClientSubject?: (clientId: string) => Promise<GuardUser | null>;
|
|
204
|
+
/** Guard name, for `auth.use(name)`. Defaults to `visa`. */
|
|
205
|
+
name?: string;
|
|
206
|
+
/**
|
|
207
|
+
* The client a token minted by {@link VisaGuard.createToken} belongs to.
|
|
208
|
+
*
|
|
209
|
+
* Upstream's guard needs no such thing because it has no clients. Here
|
|
210
|
+
* every token is issued TO somebody, and one with no client could not be
|
|
211
|
+
* introspected, revoked by its owner, or listed on a "connected
|
|
212
|
+
* applications" screen. Name the first-party application and a minted
|
|
213
|
+
* token is an ordinary token in every other respect.
|
|
214
|
+
*/
|
|
215
|
+
tokenClientId?: string;
|
|
216
|
+
/** How long a minted token lasts — upstream's `expiresIn`, in seconds. */
|
|
217
|
+
expiresInSeconds?: number;
|
|
218
|
+
/**
|
|
219
|
+
* The protected resource this guard stands in front of (RFC 9728).
|
|
220
|
+
*
|
|
221
|
+
* Given, {@link VisaGuard.challenge} returns a `WWW-Authenticate` naming
|
|
222
|
+
* the metadata document, which is how an MCP client finds the
|
|
223
|
+
* authorization server without being told where it is.
|
|
224
|
+
*/
|
|
225
|
+
resource?: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A guard, in the shape warden's config expects.
|
|
230
|
+
*
|
|
231
|
+
* Shared per application, so it holds no per-request state — the same rule
|
|
232
|
+
* warden states for its own strategies.
|
|
233
|
+
*/
|
|
234
|
+
export class VisaGuard {
|
|
235
|
+
readonly name: string;
|
|
236
|
+
readonly #config: VisaGuardConfig;
|
|
237
|
+
|
|
238
|
+
constructor(config: VisaGuardConfig) {
|
|
239
|
+
this.#config = config;
|
|
240
|
+
this.name = config.name ?? "visa";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#store(): GuardStore {
|
|
244
|
+
const store = this.#config.store;
|
|
245
|
+
return typeof store === "function" ? store() : store;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* There is no password flow here, and OAuth 2.1 removed the one there used
|
|
250
|
+
* to be. A token comes from the token endpoint.
|
|
251
|
+
*
|
|
252
|
+
* THROWS rather than answering "not authenticated", which is what warden's
|
|
253
|
+
* own access-tokens guard does: a bearer guard handed an email and a
|
|
254
|
+
* password is a misconfigured application, not a bad credential, and the two
|
|
255
|
+
* must not produce the same 401.
|
|
256
|
+
*/
|
|
257
|
+
async authenticate(): Promise<GuardResult> {
|
|
258
|
+
throw new Error(
|
|
259
|
+
"visa authenticates a bearer token, not credentials. Use verify() with the access token.",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Mint a token for `user`, as upstream's guard does.
|
|
265
|
+
*
|
|
266
|
+
* The token is a real one: stored hashed, listed, introspectable,
|
|
267
|
+
* revocable. What it skips is the authorization code flow, so it is for a
|
|
268
|
+
* FIRST-PARTY client you own — `tokenClientId` — and nothing here should
|
|
269
|
+
* ever be handed a third-party client id.
|
|
270
|
+
*/
|
|
271
|
+
async createToken(
|
|
272
|
+
user: { id: string } | string,
|
|
273
|
+
abilities: string[] = ["*"],
|
|
274
|
+
options: { name?: string; expiresInSeconds?: number } = {},
|
|
275
|
+
): Promise<VisaAccessToken> {
|
|
276
|
+
const clientId = this.#config.tokenClientId;
|
|
277
|
+
if (clientId === undefined) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"[visa] createToken needs `tokenClientId` — a token is always issued to a client. Name the first-party application in visaGuard({ tokenClientId }).",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const userId = typeof user === "string" ? user : user.id;
|
|
283
|
+
const ttl =
|
|
284
|
+
options.expiresInSeconds ?? this.#config.expiresInSeconds ?? DEFAULT_TTL;
|
|
285
|
+
const secret = randomToken();
|
|
286
|
+
const expiresAt = new Date(Date.now() + ttl * 1000);
|
|
287
|
+
|
|
288
|
+
await this.#store().saveAccessToken({
|
|
289
|
+
tokenHash: hashSecret(secret),
|
|
290
|
+
clientId,
|
|
291
|
+
userId,
|
|
292
|
+
scopes: abilities,
|
|
293
|
+
expiresAt,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return new VisaAccessToken({
|
|
297
|
+
clientId,
|
|
298
|
+
userId,
|
|
299
|
+
abilities,
|
|
300
|
+
name: options.name ?? null,
|
|
301
|
+
expiresAt,
|
|
302
|
+
// The one moment the plaintext exists.
|
|
303
|
+
value: secret,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Revoke a token — upstream's `invalidateToken`.
|
|
309
|
+
*
|
|
310
|
+
* NAMED DEVIATION — upstream's guard is built per request and invalidates
|
|
311
|
+
* the token it just authenticated with, so it takes no argument. A warden
|
|
312
|
+
* strategy is shared by the whole application and holds no per-request
|
|
313
|
+
* state, by warden's own rule, so the token to revoke is passed in. A
|
|
314
|
+
* sign-out handler has it: it is the bearer credential of the request.
|
|
315
|
+
*
|
|
316
|
+
* Answers whether the token was there to revoke.
|
|
317
|
+
*/
|
|
318
|
+
async invalidateToken(presented: string): Promise<boolean> {
|
|
319
|
+
const store = this.#store();
|
|
320
|
+
const hash = hashSecret(presented);
|
|
321
|
+
const existing = await store.findAccessToken(hash);
|
|
322
|
+
if (existing === null || existing.revokedAt !== undefined) return false;
|
|
323
|
+
await store.revokeAccessToken(hash, new Date());
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* What a test client sends to be `user` — upstream's signature.
|
|
329
|
+
*
|
|
330
|
+
* It mints a token, as upstream does, so a test authenticates the way the
|
|
331
|
+
* application will rather than against a credential the store has never
|
|
332
|
+
* seen.
|
|
333
|
+
*/
|
|
334
|
+
async authenticateAsClient(
|
|
335
|
+
user: { id: string } | string,
|
|
336
|
+
abilities: string[] = ["*"],
|
|
337
|
+
options: { name?: string; expiresInSeconds?: number } = {},
|
|
338
|
+
): Promise<GuardClientResponse> {
|
|
339
|
+
const token = await this.createToken(user, abilities, options);
|
|
340
|
+
return { headers: { authorization: `Bearer ${token.value}` } };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The `WWW-Authenticate` value a 401 from this guard should carry.
|
|
345
|
+
*
|
|
346
|
+
* Returned rather than set: a warden strategy answers whether a credential
|
|
347
|
+
* is good, and the response belongs to whoever refuses the request. An
|
|
348
|
+
* exception handler sets it — `response.header('www-authenticate',
|
|
349
|
+
* guard.challenge())` — and with it a client that has no token learns
|
|
350
|
+
* where to get one instead of hitting a dead end.
|
|
351
|
+
*/
|
|
352
|
+
challenge(
|
|
353
|
+
options: { error?: string; scope?: readonly string[] } = {},
|
|
354
|
+
): string {
|
|
355
|
+
return wwwAuthenticate({
|
|
356
|
+
...(this.#config.resource === undefined
|
|
357
|
+
? {}
|
|
358
|
+
: { resource: this.#config.resource }),
|
|
359
|
+
...(options.error === undefined ? {} : { error: options.error }),
|
|
360
|
+
...(options.scope === undefined ? {} : { scope: options.scope }),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async verify(presented: string): Promise<GuardResult> {
|
|
365
|
+
const store = this.#store();
|
|
366
|
+
const now = new Date();
|
|
367
|
+
const verified = await verifyAccessToken(presented, store, now);
|
|
368
|
+
if (verified === null) {
|
|
369
|
+
return { authenticated: false, error: "Invalid or expired access token" };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const subject =
|
|
373
|
+
verified.userId === undefined
|
|
374
|
+
? await this.#config.findClientSubject?.(verified.clientId)
|
|
375
|
+
: await this.#config.findUser(verified.userId);
|
|
376
|
+
|
|
377
|
+
if (subject === null || subject === undefined) {
|
|
378
|
+
// The account is gone or disabled since the token was minted. Refusing
|
|
379
|
+
// here is the point of asking on every request.
|
|
380
|
+
return {
|
|
381
|
+
authenticated: false,
|
|
382
|
+
error:
|
|
383
|
+
verified.userId === undefined
|
|
384
|
+
? "This token authenticates no user"
|
|
385
|
+
: "The account behind this token is no longer available",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The PREVIOUS use is what the token reports, and this one is recorded
|
|
390
|
+
// after — the same order upstream reads its row in.
|
|
391
|
+
await touch(store, presented, now);
|
|
392
|
+
|
|
393
|
+
const token = new VisaAccessToken({
|
|
394
|
+
clientId: verified.clientId,
|
|
395
|
+
...(verified.userId === undefined ? {} : { userId: verified.userId }),
|
|
396
|
+
abilities: verified.scopes,
|
|
397
|
+
expiresAt: verified.expiresAt ?? null,
|
|
398
|
+
lastUsedAt: verified.lastUsedAt ?? null,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// A copy: the provider's object is the application's, and a guard that
|
|
402
|
+
// mutated it would leak one request's token into the next.
|
|
403
|
+
const user: GuardUser = { ...subject, currentAccessToken: token };
|
|
404
|
+
if (verified.scopes.length > 0) {
|
|
405
|
+
// Scopes surface on `permissions` as well, the way warden's access-token
|
|
406
|
+
// guard does it, so `ctx.auth.user.permissions` reads the same whichever
|
|
407
|
+
// guard authenticated.
|
|
408
|
+
//
|
|
409
|
+
// They remain a SEPARATE axis from the route-level rights gate, exactly
|
|
410
|
+
// as upstream: a token ability is checked with
|
|
411
|
+
// `currentAccessToken.allows(...)`, and Bouncer does not consult it.
|
|
412
|
+
user.permissions = [
|
|
413
|
+
...new Set([...(subject.permissions ?? []), ...verified.scopes]),
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
return { authenticated: true, user };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Record that the token was just used.
|
|
422
|
+
*
|
|
423
|
+
* Never fatal: a store with no `touchAccessToken` — the contract shipped
|
|
424
|
+
* without one — still authenticates, it simply reports no last use.
|
|
425
|
+
*/
|
|
426
|
+
async function touch(
|
|
427
|
+
store: GuardStore,
|
|
428
|
+
presented: string,
|
|
429
|
+
now: Date,
|
|
430
|
+
): Promise<void> {
|
|
431
|
+
if (store.touchAccessToken === undefined) return;
|
|
432
|
+
try {
|
|
433
|
+
await store.touchAccessToken(hashSecret(presented), now);
|
|
434
|
+
} catch {
|
|
435
|
+
// Recording the use is bookkeeping; failing it must not refuse a request
|
|
436
|
+
// whose token is valid.
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Build a visa guard from its config — `visaGuard({...})` in `config/auth.ts`. */
|
|
441
|
+
export function visaGuard(config: VisaGuardConfig): VisaGuard {
|
|
442
|
+
return new VisaGuard(config);
|
|
443
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,10 +36,44 @@ export {
|
|
|
36
36
|
type AuthorizationErrorCode,
|
|
37
37
|
OAuthError,
|
|
38
38
|
type ProtocolErrorCode,
|
|
39
|
+
type RegistrationErrorCode,
|
|
39
40
|
type TokenErrorCode,
|
|
40
41
|
VisaError,
|
|
41
42
|
} from "./errors.js";
|
|
43
|
+
export {
|
|
44
|
+
type GuardClientResponse,
|
|
45
|
+
type GuardResult,
|
|
46
|
+
type GuardStore,
|
|
47
|
+
type GuardUser,
|
|
48
|
+
VisaAccessToken,
|
|
49
|
+
VisaGuard,
|
|
50
|
+
type VisaGuardConfig,
|
|
51
|
+
visaGuard,
|
|
52
|
+
} from "./guard.js";
|
|
42
53
|
export { introspect, revoke, verifyAccessToken } from "./introspect.js";
|
|
54
|
+
export {
|
|
55
|
+
assertResourceIdentifier,
|
|
56
|
+
type ChallengeOptions,
|
|
57
|
+
PROTECTED_RESOURCE_PATH,
|
|
58
|
+
type ProtectedResourceMetadata,
|
|
59
|
+
type ProtectedResourceOptions,
|
|
60
|
+
protectedResourceMetadata,
|
|
61
|
+
protectedResourceMetadataUrl,
|
|
62
|
+
wwwAuthenticate,
|
|
63
|
+
} from "./protectedResource.js";
|
|
64
|
+
export {
|
|
65
|
+
type ClientRegistrationRequest,
|
|
66
|
+
type ClientRegistrationResponse,
|
|
67
|
+
type RegistrationOptions,
|
|
68
|
+
registrationResponse,
|
|
69
|
+
validateRegistration,
|
|
70
|
+
} from "./register.js";
|
|
71
|
+
export {
|
|
72
|
+
audienceAllows,
|
|
73
|
+
narrowResources,
|
|
74
|
+
readResourceParameter,
|
|
75
|
+
validateResources,
|
|
76
|
+
} from "./resource.js";
|
|
43
77
|
export {
|
|
44
78
|
clearVisa,
|
|
45
79
|
default as visa,
|
|
@@ -47,11 +81,18 @@ export {
|
|
|
47
81
|
setVisa,
|
|
48
82
|
} from "./services/main.js";
|
|
49
83
|
export type { VisaStore } from "./store.js";
|
|
84
|
+
export {
|
|
85
|
+
type AtlasDb,
|
|
86
|
+
type AtlasQuery,
|
|
87
|
+
AtlasStore,
|
|
88
|
+
type AtlasStoreTables,
|
|
89
|
+
} from "./stores/atlas.js";
|
|
50
90
|
export { MemoryStore } from "./stores/memory.js";
|
|
51
91
|
export { type TokenOptions, type TokenRequest, token } from "./token.js";
|
|
52
92
|
export type {
|
|
53
93
|
AccessToken,
|
|
54
94
|
AuthorizationCode,
|
|
95
|
+
AuthorizedApplication,
|
|
55
96
|
Client,
|
|
56
97
|
ClientAuthMethod,
|
|
57
98
|
Consent,
|