@boopkit/cap-oauth-server 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/capability.json +120 -0
- package/dist/authorization-code-service.d.ts +105 -0
- package/dist/authorization-code-service.d.ts.map +1 -0
- package/dist/authorization-code-service.js +142 -0
- package/dist/authorization-code-service.js.map +1 -0
- package/dist/bearer.d.ts +146 -0
- package/dist/bearer.d.ts.map +1 -0
- package/dist/bearer.js +150 -0
- package/dist/bearer.js.map +1 -0
- package/dist/body-field.d.ts +24 -0
- package/dist/body-field.d.ts.map +1 -0
- package/dist/body-field.js +43 -0
- package/dist/body-field.js.map +1 -0
- package/dist/client-registry-service.d.ts +120 -0
- package/dist/client-registry-service.d.ts.map +1 -0
- package/dist/client-registry-service.js +450 -0
- package/dist/client-registry-service.js.map +1 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +157 -0
- package/dist/config.js.map +1 -0
- package/dist/connections-controller.d.ts +106 -0
- package/dist/connections-controller.d.ts.map +1 -0
- package/dist/connections-controller.js +161 -0
- package/dist/connections-controller.js.map +1 -0
- package/dist/consent-controller.d.ts +197 -0
- package/dist/consent-controller.d.ts.map +1 -0
- package/dist/consent-controller.js +532 -0
- package/dist/consent-controller.js.map +1 -0
- package/dist/discovery-controller.d.ts +16 -0
- package/dist/discovery-controller.d.ts.map +1 -0
- package/dist/discovery-controller.js +52 -0
- package/dist/discovery-controller.js.map +1 -0
- package/dist/discovery.d.ts +95 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +101 -0
- package/dist/discovery.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/express.d.ts +66 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +3 -0
- package/dist/express.js.map +1 -0
- package/dist/grant-service.d.ts +102 -0
- package/dist/grant-service.d.ts.map +1 -0
- package/dist/grant-service.js +137 -0
- package/dist/grant-service.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/metadata-document.d.ts +60 -0
- package/dist/metadata-document.d.ts.map +1 -0
- package/dist/metadata-document.js +148 -0
- package/dist/metadata-document.js.map +1 -0
- package/dist/oauth-error.d.ts +45 -0
- package/dist/oauth-error.d.ts.map +1 -0
- package/dist/oauth-error.js +51 -0
- package/dist/oauth-error.js.map +1 -0
- package/dist/pkce.d.ts +29 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +65 -0
- package/dist/pkce.js.map +1 -0
- package/dist/property-array.d.ts +60 -0
- package/dist/property-array.d.ts.map +1 -0
- package/dist/property-array.js +31 -0
- package/dist/property-array.js.map +1 -0
- package/dist/public-id.d.ts +35 -0
- package/dist/public-id.d.ts.map +1 -0
- package/dist/public-id.js +76 -0
- package/dist/public-id.js.map +1 -0
- package/dist/rate-limit.d.ts +56 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +55 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/redirect-uri.d.ts +89 -0
- package/dist/redirect-uri.d.ts.map +1 -0
- package/dist/redirect-uri.js +249 -0
- package/dist/redirect-uri.js.map +1 -0
- package/dist/registration-controller.d.ts +62 -0
- package/dist/registration-controller.d.ts.map +1 -0
- package/dist/registration-controller.js +79 -0
- package/dist/registration-controller.js.map +1 -0
- package/dist/request-client.d.ts +19 -0
- package/dist/request-client.d.ts.map +1 -0
- package/dist/request-client.js +32 -0
- package/dist/request-client.js.map +1 -0
- package/dist/revocation-controller.d.ts +28 -0
- package/dist/revocation-controller.d.ts.map +1 -0
- package/dist/revocation-controller.js +176 -0
- package/dist/revocation-controller.js.map +1 -0
- package/dist/revocation-service.d.ts +113 -0
- package/dist/revocation-service.d.ts.map +1 -0
- package/dist/revocation-service.js +121 -0
- package/dist/revocation-service.js.map +1 -0
- package/dist/token-controller.d.ts +79 -0
- package/dist/token-controller.d.ts.map +1 -0
- package/dist/token-controller.js +207 -0
- package/dist/token-controller.js.map +1 -0
- package/dist/token-service.d.ts +175 -0
- package/dist/token-service.d.ts.map +1 -0
- package/dist/token-service.js +251 -0
- package/dist/token-service.js.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/dist/user-store.d.ts +110 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +40 -0
- package/dist/user-store.js.map +1 -0
- package/package.json +37 -0
- package/scaffold/prisma/schema/oauth-clients.prisma +80 -0
- package/scaffold/reference/oauth-server.ref.md +478 -0
- package/scaffold/src/middleware/oauth-server.ts +227 -0
- package/scaffold/src/scripts/seed-oauth-clients.ts +72 -0
- package/scaffold/src/services/oauth-server.service.ts +101 -0
- package/scaffold/styles/oauth-server.css +345 -0
- package/scaffold/views/oauth-connected-clients.hbs +98 -0
- package/scaffold/views/oauth-consent-error.hbs +22 -0
- package/scaffold/views/oauth-consent.hbs +138 -0
- package/scaffold/views/oauth-disconnect-confirm.hbs +57 -0
package/capability.json
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oauth-server",
|
|
3
|
+
"description": "Be an OAuth 2.1 authorization server and resource server, so Claude, Claude Code or any MCP client can act on behalf of one of your users",
|
|
4
|
+
"type": "application",
|
|
5
|
+
"package": "@boopkit/cap-oauth-server",
|
|
6
|
+
"requires": [
|
|
7
|
+
"users-and-accounts",
|
|
8
|
+
"web",
|
|
9
|
+
"database"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
"prisma/schema/oauth-clients.prisma",
|
|
13
|
+
"src/services/oauth-server.service.ts",
|
|
14
|
+
"src/middleware/oauth-server.ts",
|
|
15
|
+
"src/scripts/seed-oauth-clients.ts",
|
|
16
|
+
"views/oauth-consent.hbs",
|
|
17
|
+
"views/oauth-consent-error.hbs",
|
|
18
|
+
"views/oauth-connected-clients.hbs",
|
|
19
|
+
"views/oauth-disconnect-confirm.hbs",
|
|
20
|
+
"styles/oauth-server.css",
|
|
21
|
+
"reference/oauth-server.ref.md"
|
|
22
|
+
],
|
|
23
|
+
"_plannedFilesNote": "Not read by boop. The scaffold surface still to land; each slice moves its entry into \"files\" as the asset arrives. Slice 2 landed the consent views and stylesheet and dropped the planned src/controllers/oauth-consent.ts: the consent controller lives in the package, where its hostile-input rules are tested, and the scaffold binds a router rather than restating handlers. Slice 3 landed the connected-clients and disconnect-confirmation views the same way, so the list is now empty; the device-flow surface joins it if Slice 5 needs a view of its own.",
|
|
24
|
+
"plannedFiles": [],
|
|
25
|
+
"hooks": [
|
|
26
|
+
{
|
|
27
|
+
"phase": "imports",
|
|
28
|
+
"code": "import { oauthDiscoveryRoutes, oauthServerRoutes } from './middleware/oauth-server';",
|
|
29
|
+
"description": "the two .well-known discovery documents, and the OAuth surface (client registration, the consent screen, POST /oauth/token, POST /oauth/revoke, and the connected-clients screen)"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"phase": "preRoutes",
|
|
33
|
+
"order": 13,
|
|
34
|
+
"code": "app.use(oauthDiscoveryRoutes);",
|
|
35
|
+
"description": "serve /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource \u2014 mounted outside /api, and first among this app's capability mounts (the free slot between cap-web's requestLogger at 12 and cap-users' cookieParser at 14), because a client reads these before it holds any credential and must never meet a 401 here. The order sequences capability mounts against each other; an application whose own middleware sits above the @boopkit:preRoutes marker still runs that middleware first, so check that these two answer 200 to a caller holding nothing \u2014 which is what the verify step below does"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"phase": "preRoutes",
|
|
39
|
+
"order": 54,
|
|
40
|
+
"code": "app.use(oauthServerRoutes);",
|
|
41
|
+
"description": "mount the OAuth surface \u2014 registration, the consent screen, POST /oauth/token, POST /oauth/revoke and the /connections management screen \u2014 after cap-users' addAuthContext (16), which the consent screen needs to decide whether it is showing a consent card, a login form or a signup form, and which /connections needs so requireAuth can gate it"
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"oauth:seed-clients": {
|
|
46
|
+
"cmd": "tsx src/scripts/seed-oauth-clients.ts",
|
|
47
|
+
"description": "Register the static OAuth clients this deployment trusts. Idempotent."
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"env": [
|
|
51
|
+
{
|
|
52
|
+
"name": "OAUTH_ISSUER_URL",
|
|
53
|
+
"description": "The origin every OAuth endpoint URL is built from, e.g. https://example.com. Must be https (localhost excepted for development). Boot fails without it, because a wrong issuer produces discovery documents that send clients somewhere else.",
|
|
54
|
+
"required": "boot"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "OAUTH_RESOURCE_URL",
|
|
58
|
+
"description": "The protected resource's URL as clients enter it, e.g. https://example.com/api/mcp. This is not the issuer: it is the string a client reads back out of /.well-known/oauth-protected-resource and must match what it connected to. Defaults to OAUTH_ISSUER_URL, which is right only for an app whose protected surface is its root.",
|
|
59
|
+
"required": "runtime"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "OAUTH_SCOPES_SUPPORTED",
|
|
63
|
+
"description": "Space- or comma-separated scopes this server will issue (default: profile email offline_access). Dropping offline_access means no client can request a refresh token, so every connection expires with its access token.",
|
|
64
|
+
"default": "profile email offline_access",
|
|
65
|
+
"required": "runtime"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "OAUTH_ACCESS_TOKEN_TTL_SECONDS",
|
|
69
|
+
"description": "Access-token lifetime in seconds (default: 900, i.e. 15 minutes). Clamped to 60-3600 with a boot warning naming the value and the bound; an app should not fail to boot over a token lifetime. Revocation does not depend on this \u2014 every request re-reads the identity row, so a revoke takes effect on the client's very next call whatever the stated lifetime.",
|
|
70
|
+
"default": "900",
|
|
71
|
+
"required": "runtime"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"name": "OAUTH_REFRESH_TOKEN_TTL_SECONDS",
|
|
75
|
+
"description": "Refresh-token lifetime in seconds (default: 7776000, i.e. 90 days), sliding on each rotation so an actively used connector never expires while an abandoned one does. Clamped to 3600-31536000 with a boot warning.",
|
|
76
|
+
"default": "7776000",
|
|
77
|
+
"required": "runtime"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"name": "OAUTH_MAX_REGISTERED_CLIENTS",
|
|
81
|
+
"description": "Ceiling on rows created by open dynamic registration (default: 1000). /oauth/register is unauthenticated by design, so this is the hard bound on what an abusive caller can write; the per-IP rate limit only makes reaching it slow.",
|
|
82
|
+
"default": "1000",
|
|
83
|
+
"required": "runtime"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "OAUTH_REGISTRATION_RATE_LIMIT",
|
|
87
|
+
"description": "Registration requests allowed per hour, per source address (default: 10). In-process and per-instance; put a shared limiter in front if you run more than one instance and need a global bound.",
|
|
88
|
+
"default": "10",
|
|
89
|
+
"required": "runtime"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"name": "OAUTH_DEVICE_CODE_ENABLED",
|
|
93
|
+
"description": "Set to the string \"true\" to enable the RFC 8628 device-code grant. Off by default. When off the endpoints are not mounted at all and the grant type is absent from the discovery metadata, rather than advertised against an endpoint that answers 404 \u2014 a client that read the metadata would treat that 404 as a server fault rather than as an unsupported feature. Not yet implemented; declared here so the flag has one name.",
|
|
94
|
+
"default": "false",
|
|
95
|
+
"required": "runtime"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name": "OAUTH_DOCUMENTATION_URL",
|
|
99
|
+
"description": "Optional human-facing documentation URL, echoed into both discovery documents as service_documentation / resource_documentation.",
|
|
100
|
+
"required": "runtime"
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
"postInstall": {
|
|
104
|
+
"prerequisites": [
|
|
105
|
+
"users-and-accounts is installed and its database migration has been run",
|
|
106
|
+
"database is installed, so prisma/schema/ exists and db:migrate is available"
|
|
107
|
+
],
|
|
108
|
+
"steps": [
|
|
109
|
+
"Set OAUTH_ISSUER_URL in .env to the origin clients will reach this app on. Set OAUTH_RESOURCE_URL too if the surface you are protecting is not the app root.",
|
|
110
|
+
"Run npm run db:migrate -- --name oauth-clients to create the oauth_clients table.",
|
|
111
|
+
"Describe every scope you advertise. Set app.locals.oauthScopeDescriptions = { profile: 'See your name and email', ... } in server.ts. Without it the consent screen shows raw scope strings, which asks a user to approve something they cannot evaluate; the app logs a boot warning naming each undescribed scope.",
|
|
112
|
+
"Add styles/oauth-server.css to your stylesheet entry point and rebuild Tailwind, or the consent screen and the connected-clients screen render unstyled.",
|
|
113
|
+
"Protect the surface you are exposing to connectors: app.use('/api/mcp', resolveOAuthBearer, requireOAuth('profile'), yourRoutes). Both are exported from src/middleware/oauth-server.ts, which also mounts GET /api/oauth/whoami as a worked example you can curl with a fresh access token. Do not mount resolveOAuthBearer on a router that already carries cap-users' resolveBearer \u2014 pick the door that router's callers use.",
|
|
114
|
+
"Edit src/scripts/seed-oauth-clients.ts with the clients you want to trust by name, then run npm run oauth:seed-clients. Clients that register themselves \u2014 anything supporting RFC 7591 or publishing a Client ID Metadata Document \u2014 need no entry there.",
|
|
115
|
+
"Decide whether open registration suits you. POST /oauth/register is unauthenticated by design, which is what the Claude ecosystem expects; if you would rather only vouch for clients yourself, remove the preRoutes hook that mounts oauthServerRoutes and keep the seed script.",
|
|
116
|
+
"Link to /connections from wherever your account settings live. The screen lists what a user has connected, states that a connected application reaches every account they belong to, and sends a disconnect through its own confirmation page. It is mounted already and gated by requireAuth; what it needs from you is a way in."
|
|
117
|
+
],
|
|
118
|
+
"verify": "Start the app and fetch /.well-known/oauth-authorization-server with no credential. You should get 200, a JSON body whose issuer matches OAUTH_ISSUER_URL, code_challenge_methods_supported of exactly [\"S256\"], and client_id_metadata_document_supported true. Then open /oauth/authorize?response_type=code&client_id=<a seeded client>&redirect_uri=<its registered URI>&code_challenge=<an S256 challenge>&code_challenge_method=S256 in a browser: you should see the consent screen, with the client's name, a provenance line, and the scopes described in your own words rather than as raw strings. Finally, sign in and open /connections: a client you have approved appears under Connected applications with the scopes described in your own words, and Disconnect leads to a confirmation page rather than revoking on the spot."
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type TokenService } from './token-service.js';
|
|
2
|
+
import type { PropertyStore } from './user-store.js';
|
|
3
|
+
/**
|
|
4
|
+
* The authorization code: one identity row, one property entry.
|
|
5
|
+
*
|
|
6
|
+
* ## The two-read model, at its smallest
|
|
7
|
+
*
|
|
8
|
+
* The identity row in the `oauth-code` domain answers *who this code belongs
|
|
9
|
+
* to*, and it is the only place the code's hash lives. The `oauth-codes`
|
|
10
|
+
* property entry answers *what was asked for* — client, redirect URI, PKCE
|
|
11
|
+
* challenge, scopes, resource — and carries the code's own `expires_at`.
|
|
12
|
+
*
|
|
13
|
+
* The entry's `code_key` is the identity key, so the two reads join. That value
|
|
14
|
+
* is a SHA-256 digest, which is what the identity row already stores; the raw
|
|
15
|
+
* code exists only in the redirect the browser followed, and nowhere at rest.
|
|
16
|
+
*
|
|
17
|
+
* ## The entry survives redemption, and that is the point
|
|
18
|
+
*
|
|
19
|
+
* Redeeming stamps `redeemed_at` on the entry and end-dates the identity row —
|
|
20
|
+
* it does not delete the entry. A deleted entry leaves nothing behind, so a
|
|
21
|
+
* replay is an unexplained failed request; a retained one records that the code
|
|
22
|
+
* was redeemed and when, which is what makes the replay an observable event
|
|
23
|
+
* rather than merely an unsuccessful one. The entry is dropped only once it has
|
|
24
|
+
* expired, on the next write that passes over it.
|
|
25
|
+
*
|
|
26
|
+
* ## Expiry here is `expires_at`, not the identity-row rule
|
|
27
|
+
*
|
|
28
|
+
* The plan's `createdAt + ttl` correction governs access and refresh tokens,
|
|
29
|
+
* whose lifetime has no home on the row. The code's lifetime does have a home:
|
|
30
|
+
* this explicit field, written at issue time. It stays the authority, and the
|
|
31
|
+
* TTL that produced it is a constant in the package rather than configuration.
|
|
32
|
+
*/
|
|
33
|
+
export declare const OAUTH_CODES_PROPERTY = "oauth-codes";
|
|
34
|
+
export declare const OAUTH_CODES_INFO_SCHEMA = "boop.oauth-server.codes.v1";
|
|
35
|
+
export interface AuthorizationCodeRecord {
|
|
36
|
+
/** `"<publicId>|<sha256>"` — the join to the identity row. Already a hash. */
|
|
37
|
+
code_key: string;
|
|
38
|
+
client_id: string;
|
|
39
|
+
client_public_id: string;
|
|
40
|
+
redirect_uri: string;
|
|
41
|
+
/** The PKCE challenge. Never the verifier — that never reaches the server. */
|
|
42
|
+
code_challenge: string;
|
|
43
|
+
code_challenge_method: string;
|
|
44
|
+
scopes: string[];
|
|
45
|
+
resource: string | null;
|
|
46
|
+
issued_at: string;
|
|
47
|
+
expires_at: string;
|
|
48
|
+
/** Set on redemption; the entry stays so a replay is visible. */
|
|
49
|
+
redeemed_at: string | null;
|
|
50
|
+
}
|
|
51
|
+
/** What redemption needs to know, beyond the code itself. */
|
|
52
|
+
export interface RedeemCodeInput {
|
|
53
|
+
code: string;
|
|
54
|
+
clientId: string;
|
|
55
|
+
clientPublicId: string;
|
|
56
|
+
redirectUri: string;
|
|
57
|
+
}
|
|
58
|
+
/** Why a redemption was refused. The controller maps these to RFC codes. */
|
|
59
|
+
export type RedeemFailure = 'unknown_code' | 'already_redeemed' | 'expired' | 'client_mismatch' | 'redirect_uri_mismatch';
|
|
60
|
+
export type RedeemResult = {
|
|
61
|
+
ok: true;
|
|
62
|
+
userId: number;
|
|
63
|
+
record: AuthorizationCodeRecord;
|
|
64
|
+
} | {
|
|
65
|
+
ok: false;
|
|
66
|
+
reason: RedeemFailure;
|
|
67
|
+
userId?: number;
|
|
68
|
+
};
|
|
69
|
+
export interface IssueCodeInput {
|
|
70
|
+
userId: number;
|
|
71
|
+
clientId: string;
|
|
72
|
+
clientPublicId: string;
|
|
73
|
+
redirectUri: string;
|
|
74
|
+
codeChallenge: string;
|
|
75
|
+
codeChallengeMethod: string;
|
|
76
|
+
scopes: string[];
|
|
77
|
+
resource?: string | null;
|
|
78
|
+
}
|
|
79
|
+
export interface AuthorizationCodeService {
|
|
80
|
+
/** Mint the code, write the row, append the entry. Returns the raw code. */
|
|
81
|
+
issue(input: IssueCodeInput): Promise<{
|
|
82
|
+
code: string;
|
|
83
|
+
record: AuthorizationCodeRecord;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* Redeem once. End-dates the identity row and stamps `redeemed_at`; a second
|
|
87
|
+
* call answers `already_redeemed` rather than `unknown_code`.
|
|
88
|
+
*/
|
|
89
|
+
redeem(input: RedeemCodeInput): Promise<RedeemResult>;
|
|
90
|
+
/** The entries a user currently holds. Used by the credentials-at-rest test. */
|
|
91
|
+
list(userId: number): Promise<AuthorizationCodeRecord[]>;
|
|
92
|
+
}
|
|
93
|
+
export interface AuthorizationCodeServiceOptions {
|
|
94
|
+
tokenService: TokenService;
|
|
95
|
+
propertyStore: PropertyStore;
|
|
96
|
+
/**
|
|
97
|
+
* How the presented `redirect_uri` is compared with the stored one. Injected
|
|
98
|
+
* so this service does not have to know about RFC 8252's loopback-port
|
|
99
|
+
* exception, which lives in `matchRedirectUri` with its own test table.
|
|
100
|
+
*/
|
|
101
|
+
matchRedirectUri(registered: string, presented: string): boolean;
|
|
102
|
+
now?: () => Date;
|
|
103
|
+
}
|
|
104
|
+
export declare function createAuthorizationCodeService(options: AuthorizationCodeServiceOptions): AuthorizationCodeService;
|
|
105
|
+
//# sourceMappingURL=authorization-code-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authorization-code-service.d.ts","sourceRoot":"","sources":["../src/authorization-code-service.ts"],"names":[],"mappings":"AACA,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,eAAO,MAAM,oBAAoB,gBAAgB,CAAC;AAClD,eAAO,MAAM,uBAAuB,+BAA+B,CAAC;AAEpE,MAAM,WAAW,uBAAuB;IACtC,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,cAAc,EAAE,MAAM,CAAC;IACvB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,6DAA6D;AAC7D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,4EAA4E;AAC5E,MAAM,MAAM,aAAa,GACrB,cAAc,GACd,kBAAkB,GAClB,SAAS,GACT,iBAAiB,GACjB,uBAAuB,CAAC;AAE5B,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,uBAAuB,CAAA;CAAE,GAC7D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,wBAAwB;IACvC,4EAA4E;IAC5E,KAAK,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,uBAAuB,CAAA;KAAE,CAAC,CAAC;IACzF;;;OAGG;IACH,MAAM,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACtD,gFAAgF;IAChF,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,EAAE,CAAC,CAAC;CAC1D;AAED,MAAM,WAAW,+BAA+B;IAC9C,YAAY,EAAE,YAAY,CAAC;IAC3B,aAAa,EAAE,aAAa,CAAC;IAC7B;;;;OAIG;IACH,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IACjE,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,wBAAwB,CA2I1B"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OAUTH_CODES_INFO_SCHEMA = exports.OAUTH_CODES_PROPERTY = void 0;
|
|
4
|
+
exports.createAuthorizationCodeService = createAuthorizationCodeService;
|
|
5
|
+
const property_array_js_1 = require("./property-array.js");
|
|
6
|
+
const token_service_js_1 = require("./token-service.js");
|
|
7
|
+
/**
|
|
8
|
+
* The authorization code: one identity row, one property entry.
|
|
9
|
+
*
|
|
10
|
+
* ## The two-read model, at its smallest
|
|
11
|
+
*
|
|
12
|
+
* The identity row in the `oauth-code` domain answers *who this code belongs
|
|
13
|
+
* to*, and it is the only place the code's hash lives. The `oauth-codes`
|
|
14
|
+
* property entry answers *what was asked for* — client, redirect URI, PKCE
|
|
15
|
+
* challenge, scopes, resource — and carries the code's own `expires_at`.
|
|
16
|
+
*
|
|
17
|
+
* The entry's `code_key` is the identity key, so the two reads join. That value
|
|
18
|
+
* is a SHA-256 digest, which is what the identity row already stores; the raw
|
|
19
|
+
* code exists only in the redirect the browser followed, and nowhere at rest.
|
|
20
|
+
*
|
|
21
|
+
* ## The entry survives redemption, and that is the point
|
|
22
|
+
*
|
|
23
|
+
* Redeeming stamps `redeemed_at` on the entry and end-dates the identity row —
|
|
24
|
+
* it does not delete the entry. A deleted entry leaves nothing behind, so a
|
|
25
|
+
* replay is an unexplained failed request; a retained one records that the code
|
|
26
|
+
* was redeemed and when, which is what makes the replay an observable event
|
|
27
|
+
* rather than merely an unsuccessful one. The entry is dropped only once it has
|
|
28
|
+
* expired, on the next write that passes over it.
|
|
29
|
+
*
|
|
30
|
+
* ## Expiry here is `expires_at`, not the identity-row rule
|
|
31
|
+
*
|
|
32
|
+
* The plan's `createdAt + ttl` correction governs access and refresh tokens,
|
|
33
|
+
* whose lifetime has no home on the row. The code's lifetime does have a home:
|
|
34
|
+
* this explicit field, written at issue time. It stays the authority, and the
|
|
35
|
+
* TTL that produced it is a constant in the package rather than configuration.
|
|
36
|
+
*/
|
|
37
|
+
exports.OAUTH_CODES_PROPERTY = 'oauth-codes';
|
|
38
|
+
exports.OAUTH_CODES_INFO_SCHEMA = 'boop.oauth-server.codes.v1';
|
|
39
|
+
function createAuthorizationCodeService(options) {
|
|
40
|
+
const { tokenService, propertyStore, matchRedirectUri } = options;
|
|
41
|
+
const now = options.now ?? (() => new Date());
|
|
42
|
+
/** Drop entries whose window has closed, so the array stays bounded. */
|
|
43
|
+
function live(entries, at) {
|
|
44
|
+
return entries.filter((entry) => Date.parse(entry.expires_at) > at.getTime());
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
async issue(input) {
|
|
48
|
+
const at = now();
|
|
49
|
+
const minted = await tokenService.mint(input.userId, token_service_js_1.OAUTH_CODE_DOMAIN, input.clientPublicId);
|
|
50
|
+
const record = {
|
|
51
|
+
code_key: minted.identityKey,
|
|
52
|
+
client_id: input.clientId,
|
|
53
|
+
client_public_id: input.clientPublicId,
|
|
54
|
+
redirect_uri: input.redirectUri,
|
|
55
|
+
code_challenge: input.codeChallenge,
|
|
56
|
+
code_challenge_method: input.codeChallengeMethod,
|
|
57
|
+
scopes: [...input.scopes],
|
|
58
|
+
resource: input.resource ?? null,
|
|
59
|
+
issued_at: at.toISOString(),
|
|
60
|
+
// The identity row's own `createdAt + ttl` agrees with this, but this
|
|
61
|
+
// field is the authority — see the header.
|
|
62
|
+
expires_at: minted.expiresAt.toISOString(),
|
|
63
|
+
redeemed_at: null,
|
|
64
|
+
};
|
|
65
|
+
const snapshot = await (0, property_array_js_1.readArray)(propertyStore, input.userId, exports.OAUTH_CODES_PROPERTY);
|
|
66
|
+
await (0, property_array_js_1.writeArray)(propertyStore, input.userId, exports.OAUTH_CODES_PROPERTY, [...live(snapshot.entries, at), record], snapshot, { infoSchema: exports.OAUTH_CODES_INFO_SCHEMA });
|
|
67
|
+
return { code: minted.token, record };
|
|
68
|
+
},
|
|
69
|
+
async redeem(input) {
|
|
70
|
+
const identityKey = tokenService.identityKeyFor(input.code);
|
|
71
|
+
if (!identityKey)
|
|
72
|
+
return { ok: false, reason: 'unknown_code' };
|
|
73
|
+
// Resolving through the token service applies the identity row's
|
|
74
|
+
// active-only lookup, so a code redeemed a moment ago (row end-dated)
|
|
75
|
+
// misses here — and the property entry below is what turns that miss into
|
|
76
|
+
// `already_redeemed` rather than `unknown_code`.
|
|
77
|
+
const resolved = await tokenService.resolve(token_service_js_1.OAUTH_CODE_DOMAIN, input.code);
|
|
78
|
+
const at = now();
|
|
79
|
+
if (!resolved) {
|
|
80
|
+
// The row is gone: never issued, already redeemed and end-dated, or
|
|
81
|
+
// expired and swept. All three are `unknown_code` here, and that is the
|
|
82
|
+
// honest answer rather than a shortcut. The row is what carries the
|
|
83
|
+
// user id, so once it is end-dated there is nothing left to look the
|
|
84
|
+
// owner up by — cap-users' lookup is active-only and the identity
|
|
85
|
+
// boundary forbids reaching past it. RFC 6749 gives all three the same
|
|
86
|
+
// wire answer (`invalid_grant`) for the same reason: telling a caller
|
|
87
|
+
// which one it hit tells it whether the code was ever real.
|
|
88
|
+
//
|
|
89
|
+
// What the retained entry buys is the record. A deleted entry leaves
|
|
90
|
+
// nothing behind; the entry kept with `redeemed_at` set means an
|
|
91
|
+
// operator reading `oauth-codes` can see that this code WAS redeemed,
|
|
92
|
+
// and when — which is what turns a replay from an unexplained failed
|
|
93
|
+
// request into an observable event.
|
|
94
|
+
return { ok: false, reason: 'unknown_code' };
|
|
95
|
+
}
|
|
96
|
+
const snapshot = await (0, property_array_js_1.readArray)(propertyStore, resolved.userId, exports.OAUTH_CODES_PROPERTY);
|
|
97
|
+
const record = snapshot.entries.find((entry) => entry.code_key === identityKey);
|
|
98
|
+
// A live identity row with no entry is a half-written issue. Refusing is
|
|
99
|
+
// right: without the entry there is no redirect URI, no challenge and no
|
|
100
|
+
// scope set, so there is nothing to check the request against.
|
|
101
|
+
if (!record)
|
|
102
|
+
return { ok: false, reason: 'unknown_code', userId: resolved.userId };
|
|
103
|
+
// Reachable while the row is still live: a second redemption that arrives
|
|
104
|
+
// between the property write and the end-date below, which is exactly the
|
|
105
|
+
// concurrent-replay case. Across time the row is already ended and the
|
|
106
|
+
// branch above answers instead.
|
|
107
|
+
if (record.redeemed_at !== null) {
|
|
108
|
+
return { ok: false, reason: 'already_redeemed', userId: resolved.userId };
|
|
109
|
+
}
|
|
110
|
+
if (Date.parse(record.expires_at) <= at.getTime()) {
|
|
111
|
+
return { ok: false, reason: 'expired', userId: resolved.userId };
|
|
112
|
+
}
|
|
113
|
+
if (record.client_id !== input.clientId || record.client_public_id !== input.clientPublicId) {
|
|
114
|
+
return { ok: false, reason: 'client_mismatch', userId: resolved.userId };
|
|
115
|
+
}
|
|
116
|
+
if (!matchRedirectUri(record.redirect_uri, input.redirectUri)) {
|
|
117
|
+
return { ok: false, reason: 'redirect_uri_mismatch', userId: resolved.userId };
|
|
118
|
+
}
|
|
119
|
+
// Order matters, and this is the order.
|
|
120
|
+
//
|
|
121
|
+
// The property write goes first because it is the ATOMIC claim: it quotes
|
|
122
|
+
// `expectedRevision`, so two simultaneous redemptions of one code cannot
|
|
123
|
+
// both succeed — the loser is refused by the revision check rather than
|
|
124
|
+
// silently applied. End-dating the row first would leave that race open,
|
|
125
|
+
// because `endDate` has no compare-and-set of its own.
|
|
126
|
+
const redeemed = { ...record, redeemed_at: at.toISOString() };
|
|
127
|
+
await (0, property_array_js_1.writeArray)(propertyStore, resolved.userId, exports.OAUTH_CODES_PROPERTY,
|
|
128
|
+
// The redeemed entry is kept until its own window closes; `live` drops
|
|
129
|
+
// only entries that have actually expired.
|
|
130
|
+
[...live(snapshot.entries, at).filter((e) => e.code_key !== identityKey), redeemed], snapshot, { infoSchema: exports.OAUTH_CODES_INFO_SCHEMA });
|
|
131
|
+
// And then the tombstone, so the code is spent across time as well as
|
|
132
|
+
// within the request.
|
|
133
|
+
await tokenService.revokeByKey(token_service_js_1.OAUTH_CODE_DOMAIN, identityKey);
|
|
134
|
+
return { ok: true, userId: resolved.userId, record: redeemed };
|
|
135
|
+
},
|
|
136
|
+
async list(userId) {
|
|
137
|
+
const snapshot = await (0, property_array_js_1.readArray)(propertyStore, userId, exports.OAUTH_CODES_PROPERTY);
|
|
138
|
+
return snapshot.entries;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=authorization-code-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authorization-code-service.js","sourceRoot":"","sources":["../src/authorization-code-service.ts"],"names":[],"mappings":"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"}
|
package/dist/bearer.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
|
+
import type { AuthMiddlewareDeps, BaseUserInfo, User } from '@boopkit/cap-users';
|
|
3
|
+
import './express.js';
|
|
4
|
+
import type { GrantService, OAuthGrantRecord } from './grant-service.js';
|
|
5
|
+
import { type TokenService } from './token-service.js';
|
|
6
|
+
/**
|
|
7
|
+
* Resource-server bearer resolution: the OAuth instantiation of cap-users'
|
|
8
|
+
* `createBearerMiddleware`.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this is a resolver and not a second middleware
|
|
11
|
+
*
|
|
12
|
+
* Slice 0 widened cap-users so that the bearer path takes its lookup as an
|
|
13
|
+
* injected callback. Everything about reading an `Authorization: Bearer` header
|
|
14
|
+
* — the parsing, the pass-through when there is no header, the 401 with a
|
|
15
|
+
* caller-chosen challenge, the deliberate refusal to fall back to the session
|
|
16
|
+
* cookie — is cap-users' and is shared with the API-key path. What is OAuth's
|
|
17
|
+
* is only the lookup, and it is three reads:
|
|
18
|
+
*
|
|
19
|
+
* 1. the `oauth-access` identity row, which answers *who*, and which applies
|
|
20
|
+
* the `createdAt + ttl` expiry rule and its lazy `endedAt` sweep;
|
|
21
|
+
* 2. the `oauth-grants` entry for that user and that client, which answers
|
|
22
|
+
* *what was approved* — and whose absence or `ended_at` is what makes a
|
|
23
|
+
* revoke take effect on the client's very next request;
|
|
24
|
+
* 3. the user itself, so `req.user` is the same principal a cookie yields.
|
|
25
|
+
*
|
|
26
|
+
* The strategy label is `'oauth'`, never `'api-key'`. `requireApiKey` gates on
|
|
27
|
+
* that literal precisely so an OAuth access token fails a gate whose purpose is
|
|
28
|
+
* to demand an independently revocable key — by construction, rather than by
|
|
29
|
+
* somebody remembering.
|
|
30
|
+
*
|
|
31
|
+
* ## The gate takes its challenge from the resolver
|
|
32
|
+
*
|
|
33
|
+
* `requireOAuth(...scopes)` below is the RFC 6750 gate. It needs the same
|
|
34
|
+
* `WWW-Authenticate` challenge the resolver sends, because the two refuse
|
|
35
|
+
* different callers — the resolver refuses a caller with a *bad* token, the
|
|
36
|
+
* gate refuses one with *no* token — and a client that meets a bare `Bearer`
|
|
37
|
+
* from one of them is told nothing about where to go next.
|
|
38
|
+
*
|
|
39
|
+
* Rather than configure the challenge twice, the resolver **writes it onto the
|
|
40
|
+
* request** (`req.oauthChallenge`) and the gate reads it back. One value, one
|
|
41
|
+
* place it is set, and the two halves cannot be given different answers. It
|
|
42
|
+
* also means a hand-built `req` in a test carries no challenge, which is a
|
|
43
|
+
* useful nudge toward testing the gate behind the real resolver — the only
|
|
44
|
+
* arrangement that can catch a resolver that stopped attaching the grant.
|
|
45
|
+
*/
|
|
46
|
+
/** What the OAuth lookup finds, before it is split across the request. */
|
|
47
|
+
export interface ResolvedOAuthPrincipal {
|
|
48
|
+
user: User<BaseUserInfo>;
|
|
49
|
+
grant: OAuthGrantRecord;
|
|
50
|
+
clientPublicId: string;
|
|
51
|
+
}
|
|
52
|
+
export interface OAuthBearerOptions {
|
|
53
|
+
tokenService: TokenService;
|
|
54
|
+
grantService: GrantService;
|
|
55
|
+
/** cap-users' `userService.findById`, injected rather than reached for. */
|
|
56
|
+
findUserById(userId: number): Promise<User<BaseUserInfo> | null>;
|
|
57
|
+
/**
|
|
58
|
+
* The `WWW-Authenticate` value on a 401.
|
|
59
|
+
*
|
|
60
|
+
* Defaults to a bare `Bearer`. A deployment that wants RFC 9728's
|
|
61
|
+
* `resource_metadata` pointer passes the fuller form, which the scaffold
|
|
62
|
+
* does. Whatever is passed here is also what `requireOAuth` sends, because
|
|
63
|
+
* the resolver hands it forward on `req.oauthChallenge`.
|
|
64
|
+
*/
|
|
65
|
+
challenge?: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The lookup itself, exported so it can be tested without a request.
|
|
69
|
+
*
|
|
70
|
+
* Returns `null` for every reason a token is not usable — malformed, unknown,
|
|
71
|
+
* revoked, expired, or belonging to a grant that has been ended. The caller
|
|
72
|
+
* cannot tell them apart, and should not: the difference is only interesting to
|
|
73
|
+
* whoever stole the token.
|
|
74
|
+
*/
|
|
75
|
+
export declare function createOAuthTokenResolver(options: OAuthBearerOptions): (token: string) => Promise<ResolvedOAuthPrincipal | null>;
|
|
76
|
+
/**
|
|
77
|
+
* The middleware an application mounts.
|
|
78
|
+
*
|
|
79
|
+
* cap-users' factory takes the lookup as a plain callback with no request in
|
|
80
|
+
* its signature — deliberately, since resolving a credential has no business
|
|
81
|
+
* reading one. But this capability has to hand the resolved grant to the
|
|
82
|
+
* handlers downstream, and the closure is the only place the two can meet, so
|
|
83
|
+
* the binding is made per request. That is a closure allocation, not work; the
|
|
84
|
+
* three reads inside it are the cost of the request either way.
|
|
85
|
+
*/
|
|
86
|
+
export declare function createOAuthBearerMiddleware(deps: AuthMiddlewareDeps, options: OAuthBearerOptions): RequestHandler;
|
|
87
|
+
/** The challenge sent when the request carried no usable OAuth token at all. */
|
|
88
|
+
export declare const DEFAULT_BEARER_CHALLENGE = "Bearer";
|
|
89
|
+
/**
|
|
90
|
+
* Which of the required scopes this grant does not cover.
|
|
91
|
+
*
|
|
92
|
+
* Set membership, so the comparison does not depend on the order either list
|
|
93
|
+
* happens to be in — a grant approved as `email profile` covers a route that
|
|
94
|
+
* asks for `profile email`. Duplicates in the requirement collapse, so the
|
|
95
|
+
* answer is stable enough to put in a header.
|
|
96
|
+
*/
|
|
97
|
+
export declare function missingScopes(granted: readonly string[], required: readonly string[]): string[];
|
|
98
|
+
/**
|
|
99
|
+
* `requireOAuth(...scopes)` — the resource-server gate.
|
|
100
|
+
*
|
|
101
|
+
* The request must have arrived on an OAuth access token backed by a live
|
|
102
|
+
* grant, and that grant must cover every named scope. Both refusals are RFC
|
|
103
|
+
* 6750:
|
|
104
|
+
*
|
|
105
|
+
* - **401** with `WWW-Authenticate: <challenge>` when there is no usable
|
|
106
|
+
* token. The challenge is the resolver's — see the file header — so it
|
|
107
|
+
* carries RFC 9728's `resource_metadata` pointer and an unauthenticated
|
|
108
|
+
* caller can find its way to the document that says where to go. Claude
|
|
109
|
+
* does not honour that header on a 200, so the 401 is the only place it can
|
|
110
|
+
* arrive.
|
|
111
|
+
* - **403** with `error="insufficient_scope"` and the required `scope`, in
|
|
112
|
+
* the header per §3.1 and in the body so a client that reads JSON errors
|
|
113
|
+
* rather than headers gets the same answer.
|
|
114
|
+
*
|
|
115
|
+
* The **grant** is the authority, not the token: a token minted under a wider
|
|
116
|
+
* consent is bound by a narrowed grant on its very next request, with no
|
|
117
|
+
* revocation involved.
|
|
118
|
+
*
|
|
119
|
+
* Mount it behind `resolveBearer`. `createOAuthResourceServer` pairs the two so
|
|
120
|
+
* an application cannot mount one without the other.
|
|
121
|
+
*/
|
|
122
|
+
export declare function requireOAuth(...requiredScopes: string[]): RequestHandler;
|
|
123
|
+
/**
|
|
124
|
+
* The resource-server pair, built from one set of options.
|
|
125
|
+
*
|
|
126
|
+
* An application needs both halves and they have to agree about one thing: the
|
|
127
|
+
* `WWW-Authenticate` challenge. A caller with no credential at all is refused
|
|
128
|
+
* by the *gate* (the resolver passes an unauthenticated request through on
|
|
129
|
+
* purpose, so it can be mounted on a router that also serves anonymous
|
|
130
|
+
* traffic), while a caller with a bad credential is refused by the *resolver*.
|
|
131
|
+
* Both therefore have to carry the pointer, which the resolver arranges by
|
|
132
|
+
* writing the challenge onto the request for the gate to read.
|
|
133
|
+
*
|
|
134
|
+
* So this factory exists for the mounting rather than for the wiring: taking
|
|
135
|
+
* both halves from one call is what stops an application mounting the gate on a
|
|
136
|
+
* router that has no resolver in front of it, where every request would be a
|
|
137
|
+
* 401 with a bare `Bearer`.
|
|
138
|
+
*/
|
|
139
|
+
export interface OAuthResourceServer {
|
|
140
|
+
/** Mount ahead of the routes you are protecting. */
|
|
141
|
+
resolveBearer: RequestHandler;
|
|
142
|
+
/** Then gate them: `requireOAuth('profile')`. */
|
|
143
|
+
requireOAuth(...scopes: string[]): RequestHandler;
|
|
144
|
+
}
|
|
145
|
+
export declare function createOAuthResourceServer(deps: AuthMiddlewareDeps, options: OAuthBearerOptions): OAuthResourceServer;
|
|
146
|
+
//# sourceMappingURL=bearer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bearer.d.ts","sourceRoot":"","sources":["../src/bearer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;AAE/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAEjF,OAAO,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACzE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,0EAA0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;IACzB,KAAK,EAAE,gBAAgB,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;IAC3B,2EAA2E;IAC3E,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,GAAG,IAAI,CAAC,CAAC;IACjE;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,kBAAkB,GAC1B,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAe3D;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CACzC,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,kBAAkB,GAC1B,cAAc,CAwBhB;AAED,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,WAAW,CAAC;AAEjD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,EAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAG/F;AAcD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,YAAY,CAAC,GAAG,cAAc,EAAE,MAAM,EAAE,GAAG,cAAc,CAgCxE;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,mBAAmB;IAClC,oDAAoD;IACpD,aAAa,EAAE,cAAc,CAAC;IAC9B,iDAAiD;IACjD,YAAY,CAAC,GAAG,MAAM,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC;CACnD;AAED,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,kBAAkB,GAC1B,mBAAmB,CAMrB"}
|