@vouchington/oauth-server 0.0.0-stage → 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 +9 -0
- package/README.md +32 -2
- package/dist/authorization.d.mts +66 -0
- package/dist/authorization.mjs +84 -0
- package/dist/client-auth.d.mts +4 -0
- package/dist/client-auth.mjs +19 -0
- package/dist/client-metadata.d.mts +7 -0
- package/dist/client-metadata.mjs +46 -0
- package/dist/errors.d.mts +13 -0
- package/dist/errors.mjs +29 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.mjs +15 -0
- package/dist/lifetimes.d.mts +4 -0
- package/dist/lifetimes.mjs +11 -0
- package/dist/metadata.d.mts +41 -0
- package/dist/metadata.mjs +35 -0
- package/dist/pkce.d.mts +6 -0
- package/dist/pkce.mjs +27 -0
- package/dist/redirect-uri.d.mts +15 -0
- package/dist/redirect-uri.mjs +67 -0
- package/dist/registration.d.mts +32 -0
- package/dist/registration.mjs +56 -0
- package/dist/request.d.mts +14 -0
- package/dist/request.mjs +76 -0
- package/dist/responses.d.mts +18 -0
- package/dist/responses.mjs +29 -0
- package/dist/revocation.d.mts +12 -0
- package/dist/revocation.mjs +13 -0
- package/dist/scopes.d.mts +17 -0
- package/dist/scopes.mjs +53 -0
- package/dist/secrets.d.mts +4 -0
- package/dist/secrets.mjs +11 -0
- package/dist/token.d.mts +19 -0
- package/dist/token.mjs +124 -0
- package/dist/types.d.mts +123 -0
- package/dist/types.mjs +1 -0
- package/package.json +37 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonathan Ong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vouchington/oauth-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Framework-agnostic OAuth 2.0 authorization-server building blocks: RFC 6749 (code and refresh
|
|
4
|
+
grants), RFC 7591 (dynamic client registration), RFC 7636 (PKCE, S256 only), RFC 7009 (revocation),
|
|
5
|
+
RFC 8414 / 9728 (metadata), RFC 8707 (resource indicators), and RFC 9207 (`iss` on responses).
|
|
6
|
+
|
|
7
|
+
The package never touches HTTP frameworks or databases. The application injects:
|
|
8
|
+
|
|
9
|
+
- `ClientStore` / `ClientRegistry` — client lookup (return only active, eligible clients) and creation.
|
|
10
|
+
- `GrantStore` — authorization-code and refresh-token persistence. `redeemAuthorizationCode` and
|
|
11
|
+
`redeemRefreshToken` run the package's verification callback inside the adapter's own
|
|
12
|
+
transaction and lock; the adapter persists the returned tokens and spends the secret atomically,
|
|
13
|
+
flags reuse with `replayed: true`, and decides what a replay or failed attempt revokes.
|
|
14
|
+
- `HashSecret` — one-way hash for client secrets, codes, and tokens. Only hashes reach the stores.
|
|
15
|
+
- `ScopePolicy` — a `ScopeCatalog` from `@vouchington/utils/scopes` plus the catalog surface.
|
|
16
|
+
- `resolveResource` — maps a `resource` URL to `{ url, audience }` or `null`.
|
|
17
|
+
- Consent, retention, ownership, rate limiting, and request routing.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import {
|
|
21
|
+
exchangeTokenGrant,
|
|
22
|
+
parseClientAuthentication,
|
|
23
|
+
parseFormBody,
|
|
24
|
+
} from '@vouchington/oauth-server'
|
|
25
|
+
|
|
26
|
+
const form = parseFormBody(contentType, body)
|
|
27
|
+
const credentials = parseClientAuthentication(authorizationHeader, form)
|
|
28
|
+
const tokens = await exchangeTokenGrant(form, credentials, { clients, grants, scopes, hashSecret })
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Errors are `OAuthProtocolError`; `buildOAuthErrorResponse(error)` renders the RFC 6749 section 5.2
|
|
32
|
+
body, status, and `WWW-Authenticate` challenge. Redirect URIs match by exact string, except RFC 8252
|
|
33
|
+
loopback registrations that omit the port. Secret and PKCE comparisons are timing-safe.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { OAuthProtocolError } from './errors.mts';
|
|
2
|
+
import type { ClientStore, GrantStore, HashSecret, OAuthClient, ResolveResource, ScopePolicy, TokenLifetimes } from './types.mts';
|
|
3
|
+
/** Raw authorization endpoint parameters; everything but `clientId` and `redirectUri` is unchecked. */
|
|
4
|
+
export interface AuthorizationParameters {
|
|
5
|
+
clientId: string;
|
|
6
|
+
redirectUri: string;
|
|
7
|
+
responseType: unknown;
|
|
8
|
+
scope: unknown;
|
|
9
|
+
resource: unknown;
|
|
10
|
+
state: unknown;
|
|
11
|
+
codeChallenge: unknown;
|
|
12
|
+
codeChallengeMethod: unknown;
|
|
13
|
+
}
|
|
14
|
+
export interface ValidatedAuthorizationRequest {
|
|
15
|
+
client: OAuthClient;
|
|
16
|
+
codeChallenge: string;
|
|
17
|
+
redirectUri: string;
|
|
18
|
+
resource: string;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
state: string;
|
|
21
|
+
}
|
|
22
|
+
export interface AuthorizationDependencies {
|
|
23
|
+
clients: ClientStore;
|
|
24
|
+
scopes: ScopePolicy;
|
|
25
|
+
resolveResource: ResolveResource;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Validates an authorization request: code response type, state, scopes, RFC 8707 resource,
|
|
29
|
+
* S256 PKCE, a known client, an exactly registered redirect URI, and client scope coverage.
|
|
30
|
+
*/
|
|
31
|
+
export declare function validateAuthorizationRequest(input: AuthorizationParameters, deps: AuthorizationDependencies): Promise<ValidatedAuthorizationRequest>;
|
|
32
|
+
/**
|
|
33
|
+
* RFC 6749 section 4.1.2.1: errors may only redirect to a registered redirect URI. Returns null
|
|
34
|
+
* when the client or URI cannot be trusted, in which case the application renders the error.
|
|
35
|
+
*/
|
|
36
|
+
export declare function authorizationErrorRedirect(input: {
|
|
37
|
+
clientId: string;
|
|
38
|
+
redirectUri: string;
|
|
39
|
+
state: unknown;
|
|
40
|
+
error: OAuthProtocolError;
|
|
41
|
+
}, deps: {
|
|
42
|
+
clients: ClientStore;
|
|
43
|
+
issuer: string;
|
|
44
|
+
}): Promise<string | null>;
|
|
45
|
+
/** Redirect URL for a user who declined consent. */
|
|
46
|
+
export declare function denyAuthorization(request: ValidatedAuthorizationRequest, issuer: string): string;
|
|
47
|
+
export interface CodeIssuanceDependencies {
|
|
48
|
+
grants: Pick<GrantStore, 'saveAuthorizationCode'>;
|
|
49
|
+
hashSecret: HashSecret;
|
|
50
|
+
issuer: string;
|
|
51
|
+
lifetimes?: Partial<Pick<TokenLifetimes, 'authorizationCodeMs'>>;
|
|
52
|
+
/** Prefix for generated authorization codes. Defaults to "code_". */
|
|
53
|
+
codePrefix?: string;
|
|
54
|
+
now?: () => Date;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* After the application records consent as `grantId` for `subject`, mints a single-use code bound
|
|
58
|
+
* to the client, redirect URI, resource, scopes and PKCE challenge, and builds the redirect URL.
|
|
59
|
+
*/
|
|
60
|
+
export declare function issueAuthorizationCode(request: ValidatedAuthorizationRequest, consent: {
|
|
61
|
+
grantId: string;
|
|
62
|
+
subject: string;
|
|
63
|
+
}, deps: CodeIssuanceDependencies): Promise<{
|
|
64
|
+
code: string;
|
|
65
|
+
redirectUrl: string;
|
|
66
|
+
}>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { OAuthProtocolError, invalidRequest } from './errors.mjs';
|
|
2
|
+
import { DEFAULT_TOKEN_LIFETIMES, DEFAULT_TOKEN_PREFIXES } from './lifetimes.mjs';
|
|
3
|
+
import { validatePkceChallenge } from './pkce.mjs';
|
|
4
|
+
import { matchesRegisteredRedirectUri } from './redirect-uri.mjs';
|
|
5
|
+
import { buildAuthorizationResponseUrl } from './responses.mjs';
|
|
6
|
+
import { assertClientMayRequestScopes, assertScopesMatchResource, parseScopeParameter, resolveRequiredResource, } from './scopes.mjs';
|
|
7
|
+
import { generateOpaqueToken } from './secrets.mjs';
|
|
8
|
+
const MAX_STATE_LENGTH = 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Validates an authorization request: code response type, state, scopes, RFC 8707 resource,
|
|
11
|
+
* S256 PKCE, a known client, an exactly registered redirect URI, and client scope coverage.
|
|
12
|
+
*/
|
|
13
|
+
export async function validateAuthorizationRequest(input, deps) {
|
|
14
|
+
if (input.responseType !== 'code') {
|
|
15
|
+
throw new OAuthProtocolError('unsupported_response_type', 'response_type must be code');
|
|
16
|
+
}
|
|
17
|
+
const state = input.state;
|
|
18
|
+
if (typeof state !== 'string' || state.length === 0 || state.length > MAX_STATE_LENGTH) {
|
|
19
|
+
throw invalidRequest('state is required');
|
|
20
|
+
}
|
|
21
|
+
const { scopes, audiences } = parseScopeParameter(input.scope, deps.scopes);
|
|
22
|
+
const resource = resolveRequiredResource(input.resource, deps.resolveResource);
|
|
23
|
+
assertScopesMatchResource(audiences, resource);
|
|
24
|
+
const codeChallenge = validatePkceChallenge(input.codeChallenge, input.codeChallengeMethod);
|
|
25
|
+
const client = await deps.clients.findClient(input.clientId);
|
|
26
|
+
if (!client)
|
|
27
|
+
throw new OAuthProtocolError('unauthorized_client', 'client is not registered');
|
|
28
|
+
if (!matchesRegisteredRedirectUri(input.redirectUri, client.redirectUris)) {
|
|
29
|
+
throw invalidRequest('redirect_uri is not registered for this client');
|
|
30
|
+
}
|
|
31
|
+
assertClientMayRequestScopes(deps.scopes, client.scopes, scopes);
|
|
32
|
+
return {
|
|
33
|
+
client,
|
|
34
|
+
codeChallenge,
|
|
35
|
+
redirectUri: input.redirectUri,
|
|
36
|
+
resource: resource.url,
|
|
37
|
+
scopes,
|
|
38
|
+
state,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* RFC 6749 section 4.1.2.1: errors may only redirect to a registered redirect URI. Returns null
|
|
43
|
+
* when the client or URI cannot be trusted, in which case the application renders the error.
|
|
44
|
+
*/
|
|
45
|
+
export async function authorizationErrorRedirect(input, deps) {
|
|
46
|
+
const client = await deps.clients.findClient(input.clientId);
|
|
47
|
+
if (!client || !matchesRegisteredRedirectUri(input.redirectUri, client.redirectUris))
|
|
48
|
+
return null;
|
|
49
|
+
return buildAuthorizationResponseUrl(input.redirectUri, {
|
|
50
|
+
error: input.error.code,
|
|
51
|
+
error_description: input.error.message,
|
|
52
|
+
...(typeof input.state === 'string' && input.state.length <= MAX_STATE_LENGTH
|
|
53
|
+
? { state: input.state }
|
|
54
|
+
: {}),
|
|
55
|
+
}, deps.issuer);
|
|
56
|
+
}
|
|
57
|
+
/** Redirect URL for a user who declined consent. */
|
|
58
|
+
export function denyAuthorization(request, issuer) {
|
|
59
|
+
return buildAuthorizationResponseUrl(request.redirectUri, { error: 'access_denied', state: request.state }, issuer);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* After the application records consent as `grantId` for `subject`, mints a single-use code bound
|
|
63
|
+
* to the client, redirect URI, resource, scopes and PKCE challenge, and builds the redirect URL.
|
|
64
|
+
*/
|
|
65
|
+
export async function issueAuthorizationCode(request, consent, deps) {
|
|
66
|
+
const code = generateOpaqueToken(deps.codePrefix ?? DEFAULT_TOKEN_PREFIXES.authorizationCode);
|
|
67
|
+
const now = deps.now?.() ?? new Date();
|
|
68
|
+
await deps.grants.saveAuthorizationCode({
|
|
69
|
+
codeHash: deps.hashSecret('authorization-code', code),
|
|
70
|
+
clientId: request.client.clientId,
|
|
71
|
+
redirectUri: request.redirectUri,
|
|
72
|
+
resource: request.resource,
|
|
73
|
+
scopes: request.scopes,
|
|
74
|
+
codeChallenge: request.codeChallenge,
|
|
75
|
+
grantId: consent.grantId,
|
|
76
|
+
subject: consent.subject,
|
|
77
|
+
expiresAt: new Date(now.getTime() +
|
|
78
|
+
(deps.lifetimes?.authorizationCodeMs ?? DEFAULT_TOKEN_LIFETIMES.authorizationCodeMs)),
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
code,
|
|
82
|
+
redirectUrl: buildAuthorizationResponseUrl(request.redirectUri, { code, state: request.state }, deps.issuer),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ClientCredentials } from './request.mts';
|
|
2
|
+
import type { ClientStore, HashSecret, OAuthClient } from './types.mts';
|
|
3
|
+
/** Authenticates a client at the token or revocation endpoint with a timing-safe secret check. */
|
|
4
|
+
export declare function authenticateClient(store: ClientStore, credentials: ClientCredentials, hashSecret: HashSecret): Promise<OAuthClient>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { invalidClient } from './errors.mjs';
|
|
2
|
+
import { safeEqual } from './secrets.mjs';
|
|
3
|
+
/** Authenticates a client at the token or revocation endpoint with a timing-safe secret check. */
|
|
4
|
+
export async function authenticateClient(store, credentials, hashSecret) {
|
|
5
|
+
const client = await store.findClient(credentials.clientId);
|
|
6
|
+
if (!client)
|
|
7
|
+
throw invalidClient();
|
|
8
|
+
if (client.tokenEndpointAuthMethod === 'none') {
|
|
9
|
+
if (credentials.clientSecret !== undefined)
|
|
10
|
+
throw invalidClient('public client sent a secret');
|
|
11
|
+
return client;
|
|
12
|
+
}
|
|
13
|
+
if (!credentials.clientSecret || !client.clientSecretHash)
|
|
14
|
+
throw invalidClient();
|
|
15
|
+
const presented = hashSecret('client-secret', credentials.clientSecret);
|
|
16
|
+
if (!safeEqual(presented, client.clientSecretHash))
|
|
17
|
+
throw invalidClient();
|
|
18
|
+
return client;
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ClientAuthMethod } from './types.mts';
|
|
2
|
+
export declare function validateRegistrationObject(input: unknown): Record<string, unknown>;
|
|
3
|
+
export declare function validateClientName(value: unknown): string;
|
|
4
|
+
/** RFC 7591 section 2 defaults an omitted method to client_secret_basic; callers may override. */
|
|
5
|
+
export declare function validateAuthMethod(value: unknown, defaultMethod?: ClientAuthMethod): ClientAuthMethod;
|
|
6
|
+
export declare function validateGrantTypes(value: unknown): string[];
|
|
7
|
+
export declare function validateResponseTypes(value: unknown): string[];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { invalidClientMetadata } from './errors.mjs';
|
|
2
|
+
// RFC 7591 client-metadata validators. Only the code flow with refresh tokens is supported.
|
|
3
|
+
export function validateRegistrationObject(input) {
|
|
4
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
5
|
+
throw invalidClientMetadata('registration metadata must be a JSON object');
|
|
6
|
+
}
|
|
7
|
+
return input;
|
|
8
|
+
}
|
|
9
|
+
export function validateClientName(value) {
|
|
10
|
+
if (typeof value !== 'string' ||
|
|
11
|
+
value.trim().length === 0 ||
|
|
12
|
+
value.length > 120 ||
|
|
13
|
+
/[\p{Cc}\p{Cf}]/u.test(value)) {
|
|
14
|
+
throw invalidClientMetadata('client_name must contain between 1 and 120 characters');
|
|
15
|
+
}
|
|
16
|
+
return value.trim();
|
|
17
|
+
}
|
|
18
|
+
/** RFC 7591 section 2 defaults an omitted method to client_secret_basic; callers may override. */
|
|
19
|
+
export function validateAuthMethod(value, defaultMethod = 'client_secret_basic') {
|
|
20
|
+
if (value === undefined)
|
|
21
|
+
return defaultMethod;
|
|
22
|
+
if (value === 'none' || value === 'client_secret_basic')
|
|
23
|
+
return value;
|
|
24
|
+
throw invalidClientMetadata('token_endpoint_auth_method is not supported');
|
|
25
|
+
}
|
|
26
|
+
export function validateGrantTypes(value) {
|
|
27
|
+
const grantTypes = value ?? ['authorization_code', 'refresh_token'];
|
|
28
|
+
if (!Array.isArray(grantTypes) || grantTypes.length === 0) {
|
|
29
|
+
throw invalidClientMetadata('grant_types is invalid');
|
|
30
|
+
}
|
|
31
|
+
const unique = new Set(grantTypes);
|
|
32
|
+
if (unique.size !== grantTypes.length ||
|
|
33
|
+
!unique.has('authorization_code') ||
|
|
34
|
+
!unique.has('refresh_token') ||
|
|
35
|
+
unique.size !== 2) {
|
|
36
|
+
throw invalidClientMetadata('grant_types is not supported');
|
|
37
|
+
}
|
|
38
|
+
return ['authorization_code', 'refresh_token'];
|
|
39
|
+
}
|
|
40
|
+
export function validateResponseTypes(value) {
|
|
41
|
+
const responseTypes = value ?? ['code'];
|
|
42
|
+
if (!Array.isArray(responseTypes) || responseTypes.length !== 1 || responseTypes[0] !== 'code') {
|
|
43
|
+
throw invalidClientMetadata('only the code response type is supported');
|
|
44
|
+
}
|
|
45
|
+
return ['code'];
|
|
46
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type OAuthErrorCode = 'access_denied' | 'invalid_client' | 'invalid_client_metadata' | 'invalid_grant' | 'invalid_redirect_uri' | 'invalid_request' | 'invalid_scope' | 'invalid_target' | 'server_error' | 'unauthorized_client' | 'unsupported_grant_type' | 'unsupported_response_type';
|
|
2
|
+
/** Protocol failure carrying the RFC 6749 error code and the HTTP status a token endpoint uses. */
|
|
3
|
+
export declare class OAuthProtocolError extends Error {
|
|
4
|
+
readonly code: OAuthErrorCode;
|
|
5
|
+
readonly status: number;
|
|
6
|
+
constructor(code: OAuthErrorCode, description: string, status?: number, options?: ErrorOptions);
|
|
7
|
+
}
|
|
8
|
+
export declare function invalidRequest(description: string): OAuthProtocolError;
|
|
9
|
+
export declare function invalidGrant(description: string): OAuthProtocolError;
|
|
10
|
+
export declare function invalidTarget(): OAuthProtocolError;
|
|
11
|
+
export declare function invalidClientMetadata(description: string): OAuthProtocolError;
|
|
12
|
+
export declare function invalidRedirectUri(description: string): OAuthProtocolError;
|
|
13
|
+
export declare function invalidClient(description?: string): OAuthProtocolError;
|
package/dist/errors.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Protocol failure carrying the RFC 6749 error code and the HTTP status a token endpoint uses. */
|
|
2
|
+
export class OAuthProtocolError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
constructor(code, description, status = 400, options) {
|
|
6
|
+
super(description, options);
|
|
7
|
+
this.name = 'OAuthProtocolError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function invalidRequest(description) {
|
|
13
|
+
return new OAuthProtocolError('invalid_request', description);
|
|
14
|
+
}
|
|
15
|
+
export function invalidGrant(description) {
|
|
16
|
+
return new OAuthProtocolError('invalid_grant', description);
|
|
17
|
+
}
|
|
18
|
+
export function invalidTarget() {
|
|
19
|
+
return new OAuthProtocolError('invalid_target', 'resource is not supported');
|
|
20
|
+
}
|
|
21
|
+
export function invalidClientMetadata(description) {
|
|
22
|
+
return new OAuthProtocolError('invalid_client_metadata', description);
|
|
23
|
+
}
|
|
24
|
+
export function invalidRedirectUri(description) {
|
|
25
|
+
return new OAuthProtocolError('invalid_redirect_uri', description);
|
|
26
|
+
}
|
|
27
|
+
export function invalidClient(description = 'client authentication failed') {
|
|
28
|
+
return new OAuthProtocolError('invalid_client', description, 401);
|
|
29
|
+
}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './authorization.mts';
|
|
2
|
+
export * from './client-auth.mts';
|
|
3
|
+
export * from './client-metadata.mts';
|
|
4
|
+
export * from './errors.mts';
|
|
5
|
+
export * from './lifetimes.mts';
|
|
6
|
+
export * from './metadata.mts';
|
|
7
|
+
export * from './pkce.mts';
|
|
8
|
+
export * from './redirect-uri.mts';
|
|
9
|
+
export * from './registration.mts';
|
|
10
|
+
export * from './request.mts';
|
|
11
|
+
export * from './responses.mts';
|
|
12
|
+
export * from './revocation.mts';
|
|
13
|
+
export * from './scopes.mts';
|
|
14
|
+
export * from './secrets.mts';
|
|
15
|
+
export * from './token.mts';
|
|
16
|
+
export type * from './types.mts';
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from './authorization.mjs';
|
|
2
|
+
export * from './client-auth.mjs';
|
|
3
|
+
export * from './client-metadata.mjs';
|
|
4
|
+
export * from './errors.mjs';
|
|
5
|
+
export * from './lifetimes.mjs';
|
|
6
|
+
export * from './metadata.mjs';
|
|
7
|
+
export * from './pkce.mjs';
|
|
8
|
+
export * from './redirect-uri.mjs';
|
|
9
|
+
export * from './registration.mjs';
|
|
10
|
+
export * from './request.mjs';
|
|
11
|
+
export * from './responses.mjs';
|
|
12
|
+
export * from './revocation.mjs';
|
|
13
|
+
export * from './scopes.mjs';
|
|
14
|
+
export * from './secrets.mjs';
|
|
15
|
+
export * from './token.mjs';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Safe defaults; callers override any of them. */
|
|
2
|
+
export const DEFAULT_TOKEN_LIFETIMES = {
|
|
3
|
+
authorizationCodeMs: 5 * 60 * 1000,
|
|
4
|
+
accessTokenMs: 60 * 60 * 1000,
|
|
5
|
+
refreshTokenMs: 30 * 24 * 60 * 60 * 1000,
|
|
6
|
+
};
|
|
7
|
+
export const DEFAULT_TOKEN_PREFIXES = {
|
|
8
|
+
authorizationCode: 'code_',
|
|
9
|
+
accessToken: 'access_',
|
|
10
|
+
refreshToken: 'refresh_',
|
|
11
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface AuthorizationServerMetadataOptions {
|
|
2
|
+
issuer: string;
|
|
3
|
+
scopesSupported: readonly string[];
|
|
4
|
+
/** Absolute endpoint URLs; each defaults to the issuer plus its conventional path. */
|
|
5
|
+
endpoints?: {
|
|
6
|
+
authorization?: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
registration?: string;
|
|
9
|
+
revocation?: string;
|
|
10
|
+
};
|
|
11
|
+
/** Extra RFC 8414 members, such as application-specific capability flags. */
|
|
12
|
+
extra?: Readonly<Record<string, unknown>>;
|
|
13
|
+
}
|
|
14
|
+
/** RFC 8414 metadata for the supported subset: code + refresh grants, S256 PKCE, RFC 9207 iss. */
|
|
15
|
+
export declare function buildAuthorizationServerMetadata(options: AuthorizationServerMetadataOptions): {
|
|
16
|
+
issuer: string;
|
|
17
|
+
authorization_endpoint: string;
|
|
18
|
+
token_endpoint: string;
|
|
19
|
+
registration_endpoint: string;
|
|
20
|
+
revocation_endpoint: string;
|
|
21
|
+
response_types_supported: string[];
|
|
22
|
+
grant_types_supported: string[];
|
|
23
|
+
code_challenge_methods_supported: string[];
|
|
24
|
+
token_endpoint_auth_methods_supported: string[];
|
|
25
|
+
revocation_endpoint_auth_methods_supported: string[];
|
|
26
|
+
scopes_supported: string[];
|
|
27
|
+
authorization_response_iss_parameter_supported: boolean;
|
|
28
|
+
};
|
|
29
|
+
/** RFC 9728 metadata document for one protected resource. */
|
|
30
|
+
export declare function buildProtectedResourceMetadata(options: {
|
|
31
|
+
resource: string;
|
|
32
|
+
issuer: string;
|
|
33
|
+
scopesSupported: readonly string[];
|
|
34
|
+
}): {
|
|
35
|
+
resource: string;
|
|
36
|
+
authorization_servers: string[];
|
|
37
|
+
bearer_methods_supported: string[];
|
|
38
|
+
scopes_supported: string[];
|
|
39
|
+
};
|
|
40
|
+
/** RFC 9728 inserts the well-known segment between the origin and the resource path. */
|
|
41
|
+
export declare function protectedResourceMetadataUrl(resource: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** RFC 8414 metadata for the supported subset: code + refresh grants, S256 PKCE, RFC 9207 iss. */
|
|
2
|
+
export function buildAuthorizationServerMetadata(options) {
|
|
3
|
+
const { issuer, endpoints = {} } = options;
|
|
4
|
+
const methods = ['client_secret_basic', 'none'];
|
|
5
|
+
return {
|
|
6
|
+
issuer,
|
|
7
|
+
authorization_endpoint: endpoints.authorization ?? `${issuer}/authorize`,
|
|
8
|
+
token_endpoint: endpoints.token ?? `${issuer}/token`,
|
|
9
|
+
registration_endpoint: endpoints.registration ?? `${issuer}/register`,
|
|
10
|
+
revocation_endpoint: endpoints.revocation ?? `${issuer}/revoke`,
|
|
11
|
+
response_types_supported: ['code'],
|
|
12
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
13
|
+
code_challenge_methods_supported: ['S256'],
|
|
14
|
+
token_endpoint_auth_methods_supported: methods,
|
|
15
|
+
revocation_endpoint_auth_methods_supported: methods,
|
|
16
|
+
scopes_supported: options.scopesSupported.toSorted(),
|
|
17
|
+
authorization_response_iss_parameter_supported: true,
|
|
18
|
+
...options.extra,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** RFC 9728 metadata document for one protected resource. */
|
|
22
|
+
export function buildProtectedResourceMetadata(options) {
|
|
23
|
+
return {
|
|
24
|
+
resource: options.resource,
|
|
25
|
+
authorization_servers: [options.issuer],
|
|
26
|
+
bearer_methods_supported: ['header'],
|
|
27
|
+
scopes_supported: options.scopesSupported.toSorted(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** RFC 9728 inserts the well-known segment between the origin and the resource path. */
|
|
31
|
+
export function protectedResourceMetadataUrl(resource) {
|
|
32
|
+
const url = new URL(resource);
|
|
33
|
+
const path = url.pathname === '/' ? '' : url.pathname;
|
|
34
|
+
return `${url.origin}/.well-known/oauth-protected-resource${path}${url.search}`;
|
|
35
|
+
}
|
package/dist/pkce.d.mts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** RFC 7636 S256 challenge for a verifier. The plain method is never accepted. */
|
|
2
|
+
export declare function createPkceChallenge(verifier: string): string;
|
|
3
|
+
export declare function validatePkceChallenge(challenge: unknown, method: unknown): string;
|
|
4
|
+
export declare function validatePkceVerifier(verifier: unknown): string;
|
|
5
|
+
/** Timing-safe check that `verifier` produced the stored S256 `challenge`. */
|
|
6
|
+
export declare function verifyPkce(challenge: string, verifier: string): boolean;
|
package/dist/pkce.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { invalidGrant, invalidRequest } from './errors.mjs';
|
|
3
|
+
import { safeEqual } from './secrets.mjs';
|
|
4
|
+
const PKCE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
5
|
+
const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
6
|
+
/** RFC 7636 S256 challenge for a verifier. The plain method is never accepted. */
|
|
7
|
+
export function createPkceChallenge(verifier) {
|
|
8
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
9
|
+
}
|
|
10
|
+
export function validatePkceChallenge(challenge, method) {
|
|
11
|
+
if (method !== 'S256')
|
|
12
|
+
throw invalidRequest('code_challenge_method must be S256');
|
|
13
|
+
if (typeof challenge !== 'string' || !PKCE_CHALLENGE_PATTERN.test(challenge)) {
|
|
14
|
+
throw invalidRequest('code_challenge is invalid');
|
|
15
|
+
}
|
|
16
|
+
return challenge;
|
|
17
|
+
}
|
|
18
|
+
export function validatePkceVerifier(verifier) {
|
|
19
|
+
if (typeof verifier !== 'string' || !PKCE_VERIFIER_PATTERN.test(verifier)) {
|
|
20
|
+
throw invalidGrant('code_verifier is invalid');
|
|
21
|
+
}
|
|
22
|
+
return verifier;
|
|
23
|
+
}
|
|
24
|
+
/** Timing-safe check that `verifier` produced the stored S256 `challenge`. */
|
|
25
|
+
export function verifyPkce(challenge, verifier) {
|
|
26
|
+
return safeEqual(challenge, createPkceChallenge(verifier));
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const MAX_REDIRECT_URIS = 10;
|
|
2
|
+
export declare const MAX_REDIRECT_URI_LENGTH = 2048;
|
|
3
|
+
/**
|
|
4
|
+
* Validates registered redirect URIs: HTTPS or loopback HTTP, no wildcards, userinfo or fragments,
|
|
5
|
+
* no duplicates. By default URIs are serialized; pass `serialize: false` to keep the exact strings
|
|
6
|
+
* for simple string comparison.
|
|
7
|
+
*/
|
|
8
|
+
export declare function validateRedirectUris(values: unknown, options?: {
|
|
9
|
+
serialize?: boolean;
|
|
10
|
+
}): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Exact string match. RFC 8252 loopback registrations (127.0.0.1 and [::1]) may omit the ephemeral
|
|
13
|
+
* port; every other part of the URI must still match exactly.
|
|
14
|
+
*/
|
|
15
|
+
export declare function matchesRegisteredRedirectUri(requestedUri: string, registeredUris: readonly string[]): boolean;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { invalidRedirectUri } from './errors.mjs';
|
|
2
|
+
export const MAX_REDIRECT_URIS = 10;
|
|
3
|
+
export const MAX_REDIRECT_URI_LENGTH = 2048;
|
|
4
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
5
|
+
const LOOPBACK_REQUEST = /^http:\/\/(127\.0\.0\.1|\[::1\]):([0-9]+)([/?].*)?$/u;
|
|
6
|
+
/**
|
|
7
|
+
* Validates registered redirect URIs: HTTPS or loopback HTTP, no wildcards, userinfo or fragments,
|
|
8
|
+
* no duplicates. By default URIs are serialized; pass `serialize: false` to keep the exact strings
|
|
9
|
+
* for simple string comparison.
|
|
10
|
+
*/
|
|
11
|
+
export function validateRedirectUris(values, options = {}) {
|
|
12
|
+
if (!Array.isArray(values) || values.length === 0 || values.length > MAX_REDIRECT_URIS) {
|
|
13
|
+
throw invalidRedirectUri(`redirect_uris must contain between 1 and ${MAX_REDIRECT_URIS} entries`);
|
|
14
|
+
}
|
|
15
|
+
const result = [];
|
|
16
|
+
for (const value of values)
|
|
17
|
+
result.push(validateOne(value, options.serialize !== false));
|
|
18
|
+
if (new Set(result).size !== result.length) {
|
|
19
|
+
throw invalidRedirectUri('redirect_uris contains duplicates');
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
function validateOne(value, serialize) {
|
|
24
|
+
if (typeof value !== 'string' ||
|
|
25
|
+
value.length > MAX_REDIRECT_URI_LENGTH ||
|
|
26
|
+
/[\s\p{Cc}\p{Z}]/u.test(value)) {
|
|
27
|
+
throw invalidRedirectUri('redirect_uris contains an invalid URI');
|
|
28
|
+
}
|
|
29
|
+
let uri;
|
|
30
|
+
try {
|
|
31
|
+
uri = new URL(value);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw invalidRedirectUri('redirect_uris contains an invalid URI');
|
|
35
|
+
}
|
|
36
|
+
if (value.includes('*'))
|
|
37
|
+
throw invalidRedirectUri('redirect URIs cannot contain wildcards');
|
|
38
|
+
if (uri.username || uri.password || uri.hash) {
|
|
39
|
+
throw invalidRedirectUri('redirect URIs cannot contain userinfo or fragments');
|
|
40
|
+
}
|
|
41
|
+
const loopbackHttp = uri.protocol === 'http:' && LOOPBACK_HOSTNAMES.has(uri.hostname);
|
|
42
|
+
if (uri.protocol !== 'https:' && !loopbackHttp) {
|
|
43
|
+
throw invalidRedirectUri('redirect URIs must use HTTPS or loopback HTTP');
|
|
44
|
+
}
|
|
45
|
+
const redirectUri = serialize ? uri.toString() : value;
|
|
46
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
47
|
+
throw invalidRedirectUri('redirect_uris contains an invalid URI');
|
|
48
|
+
}
|
|
49
|
+
return redirectUri;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Exact string match. RFC 8252 loopback registrations (127.0.0.1 and [::1]) may omit the ephemeral
|
|
53
|
+
* port; every other part of the URI must still match exactly.
|
|
54
|
+
*/
|
|
55
|
+
export function matchesRegisteredRedirectUri(requestedUri, registeredUris) {
|
|
56
|
+
if (registeredUris.includes(requestedUri))
|
|
57
|
+
return true;
|
|
58
|
+
const requested = LOOPBACK_REQUEST.exec(requestedUri);
|
|
59
|
+
if (!requested)
|
|
60
|
+
return false;
|
|
61
|
+
if (Number(requested[2]) > 65_535)
|
|
62
|
+
return false;
|
|
63
|
+
const suffix = requested[3] ?? '';
|
|
64
|
+
// Registration serializes a portless root URI with a trailing slash.
|
|
65
|
+
return (registeredUris.includes(`http://${requested[1]}${suffix}`) ||
|
|
66
|
+
(suffix === '' && registeredUris.includes(`http://${requested[1]}/`)));
|
|
67
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ClientAuthMethod, ClientRegistry, HashSecret, ScopePolicy } from './types.mts';
|
|
2
|
+
export interface RegistrationDependencies {
|
|
3
|
+
registry: ClientRegistry;
|
|
4
|
+
scopes: ScopePolicy;
|
|
5
|
+
hashSecret: HashSecret;
|
|
6
|
+
/** Prefix for generated client ids. Defaults to "client_". */
|
|
7
|
+
clientIdPrefix?: string;
|
|
8
|
+
/** Prefix for generated client secrets. Defaults to "secret_". */
|
|
9
|
+
clientSecretPrefix?: string;
|
|
10
|
+
/** Used when the request omits token_endpoint_auth_method. Defaults to client_secret_basic. */
|
|
11
|
+
defaultAuthMethod?: ClientAuthMethod;
|
|
12
|
+
now?: () => Date;
|
|
13
|
+
}
|
|
14
|
+
/** RFC 7591 section 3.2.1 client information response. */
|
|
15
|
+
export interface RegisteredClient {
|
|
16
|
+
client_id: string;
|
|
17
|
+
client_id_issued_at: number;
|
|
18
|
+
client_name: string;
|
|
19
|
+
client_secret?: string;
|
|
20
|
+
client_secret_expires_at?: 0;
|
|
21
|
+
grant_types: string[];
|
|
22
|
+
redirect_uris: string[];
|
|
23
|
+
response_types: string[];
|
|
24
|
+
scope: string;
|
|
25
|
+
token_endpoint_auth_method: ClientAuthMethod;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Validates RFC 7591 metadata and persists the client through the injected registry. The secret is
|
|
29
|
+
* returned once and only its hash is stored. `context` is passed through to `createClient` so the
|
|
30
|
+
* application can attach ownership or other policy.
|
|
31
|
+
*/
|
|
32
|
+
export declare function registerClient(input: unknown, deps: RegistrationDependencies, context?: unknown): Promise<RegisteredClient>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { validateAuthMethod, validateClientName, validateGrantTypes, validateRegistrationObject, validateResponseTypes, } from './client-metadata.mjs';
|
|
2
|
+
import { OAuthProtocolError, invalidClientMetadata } from './errors.mjs';
|
|
3
|
+
import { validateRedirectUris } from './redirect-uri.mjs';
|
|
4
|
+
import { parseScopeParameter } from './scopes.mjs';
|
|
5
|
+
import { generateOpaqueToken } from './secrets.mjs';
|
|
6
|
+
/**
|
|
7
|
+
* Validates RFC 7591 metadata and persists the client through the injected registry. The secret is
|
|
8
|
+
* returned once and only its hash is stored. `context` is passed through to `createClient` so the
|
|
9
|
+
* application can attach ownership or other policy.
|
|
10
|
+
*/
|
|
11
|
+
export async function registerClient(input, deps, context) {
|
|
12
|
+
const metadata = validateRegistrationObject(input);
|
|
13
|
+
const clientName = validateClientName(metadata.client_name);
|
|
14
|
+
const redirectUris = validateRedirectUris(metadata.redirect_uris);
|
|
15
|
+
const method = validateAuthMethod(metadata.token_endpoint_auth_method, deps.defaultAuthMethod);
|
|
16
|
+
const grantTypes = validateGrantTypes(metadata.grant_types);
|
|
17
|
+
const responseTypes = validateResponseTypes(metadata.response_types);
|
|
18
|
+
const scopes = parseRegistrationScopes(metadata.scope, deps.scopes);
|
|
19
|
+
const clientId = generateOpaqueToken(deps.clientIdPrefix ?? 'client_', 24);
|
|
20
|
+
const clientSecret = method === 'client_secret_basic'
|
|
21
|
+
? generateOpaqueToken(deps.clientSecretPrefix ?? 'secret_')
|
|
22
|
+
: undefined;
|
|
23
|
+
const issuedAt = deps.now?.() ?? new Date();
|
|
24
|
+
await deps.registry.createClient({
|
|
25
|
+
clientId,
|
|
26
|
+
clientName,
|
|
27
|
+
redirectUris,
|
|
28
|
+
scopes,
|
|
29
|
+
tokenEndpointAuthMethod: method,
|
|
30
|
+
clientSecretHash: clientSecret ? deps.hashSecret('client-secret', clientSecret) : null,
|
|
31
|
+
grantTypes,
|
|
32
|
+
responseTypes,
|
|
33
|
+
issuedAt,
|
|
34
|
+
}, context);
|
|
35
|
+
return {
|
|
36
|
+
client_id: clientId,
|
|
37
|
+
client_id_issued_at: Math.floor(issuedAt.getTime() / 1000),
|
|
38
|
+
client_name: clientName,
|
|
39
|
+
...(clientSecret ? { client_secret: clientSecret, client_secret_expires_at: 0 } : {}),
|
|
40
|
+
grant_types: grantTypes,
|
|
41
|
+
redirect_uris: redirectUris,
|
|
42
|
+
response_types: responseTypes,
|
|
43
|
+
scope: scopes.join(' '),
|
|
44
|
+
token_endpoint_auth_method: method,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function parseRegistrationScopes(value, policy) {
|
|
48
|
+
try {
|
|
49
|
+
return parseScopeParameter(value, policy).scopes;
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (err instanceof OAuthProtocolError)
|
|
53
|
+
throw invalidClientMetadata(err.message);
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ClientCredentials {
|
|
2
|
+
clientId: string;
|
|
3
|
+
clientSecret?: string;
|
|
4
|
+
}
|
|
5
|
+
/** Parses an RFC 6749 form body, rejecting other media types and repeated parameters. */
|
|
6
|
+
export declare function parseFormBody(contentType: string | undefined, body: Buffer | string): URLSearchParams;
|
|
7
|
+
/** Parses an RFC 7591 registration body, which must be a JSON object. */
|
|
8
|
+
export declare function parseRegistrationBody(contentType: string | undefined, body: Buffer | string): unknown;
|
|
9
|
+
export declare function requiredFormValue(form: URLSearchParams, name: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Reads client credentials from HTTP Basic or, for public clients, a bare `client_id`.
|
|
12
|
+
* `client_secret_post` and mixing both methods are rejected.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseClientAuthentication(authorization: string | undefined, form: URLSearchParams): ClientCredentials;
|
package/dist/request.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { invalidClient, invalidClientMetadata, invalidRequest } from './errors.mjs';
|
|
2
|
+
function mediaType(contentType) {
|
|
3
|
+
return contentType?.split(';', 1)[0]?.trim().toLowerCase();
|
|
4
|
+
}
|
|
5
|
+
/** Parses an RFC 6749 form body, rejecting other media types and repeated parameters. */
|
|
6
|
+
export function parseFormBody(contentType, body) {
|
|
7
|
+
if (mediaType(contentType) !== 'application/x-www-form-urlencoded') {
|
|
8
|
+
throw invalidRequest('content type must be form encoded');
|
|
9
|
+
}
|
|
10
|
+
const form = new URLSearchParams(typeof body === 'string' ? body : body.toString('utf8'));
|
|
11
|
+
for (const name of new Set(form.keys())) {
|
|
12
|
+
if (form.getAll(name).length !== 1)
|
|
13
|
+
throw invalidRequest(`${name} must not be repeated`);
|
|
14
|
+
}
|
|
15
|
+
return form;
|
|
16
|
+
}
|
|
17
|
+
/** Parses an RFC 7591 registration body, which must be a JSON object. */
|
|
18
|
+
export function parseRegistrationBody(contentType, body) {
|
|
19
|
+
if (mediaType(contentType) !== 'application/json') {
|
|
20
|
+
throw invalidClientMetadata('registration metadata must use application/json');
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(typeof body === 'string' ? body : body.toString('utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw invalidClientMetadata('registration metadata must be valid JSON');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function requiredFormValue(form, name) {
|
|
30
|
+
const value = form.get(name);
|
|
31
|
+
if (!value)
|
|
32
|
+
throw invalidRequest(`${name} is required`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Reads client credentials from HTTP Basic or, for public clients, a bare `client_id`.
|
|
37
|
+
* `client_secret_post` and mixing both methods are rejected.
|
|
38
|
+
*/
|
|
39
|
+
export function parseClientAuthentication(authorization, form) {
|
|
40
|
+
const bodyClientId = form.get('client_id');
|
|
41
|
+
const bodyClientSecret = form.get('client_secret');
|
|
42
|
+
if (!authorization) {
|
|
43
|
+
if (!bodyClientId)
|
|
44
|
+
throw invalidClient('client_id is required');
|
|
45
|
+
if (bodyClientSecret !== null)
|
|
46
|
+
throw invalidClient('client_secret_post is not supported');
|
|
47
|
+
return { clientId: bodyClientId };
|
|
48
|
+
}
|
|
49
|
+
const match = /^basic +(.+)$/i.exec(authorization);
|
|
50
|
+
if (!match || bodyClientId || bodyClientSecret !== null)
|
|
51
|
+
throw invalidClient();
|
|
52
|
+
const decoded = decodeBasicCredentials(match[1]);
|
|
53
|
+
const separator = decoded.indexOf(':');
|
|
54
|
+
if (separator < 1)
|
|
55
|
+
throw invalidClient();
|
|
56
|
+
return {
|
|
57
|
+
clientId: decodeFormComponent(decoded.slice(0, separator)),
|
|
58
|
+
clientSecret: decodeFormComponent(decoded.slice(separator + 1)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function decodeBasicCredentials(encoded) {
|
|
62
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0)
|
|
63
|
+
throw invalidClient();
|
|
64
|
+
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
|
|
65
|
+
if (decoded.includes('�'))
|
|
66
|
+
throw invalidClient();
|
|
67
|
+
return decoded;
|
|
68
|
+
}
|
|
69
|
+
function decodeFormComponent(value) {
|
|
70
|
+
try {
|
|
71
|
+
return decodeURIComponent(value.replaceAll('+', ' '));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw invalidClient();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OAuthProtocolError } from './errors.mts';
|
|
2
|
+
/** Headers every authorization-server response should carry. */
|
|
3
|
+
export declare const OAUTH_RESPONSE_HEADERS: Readonly<Record<string, string>>;
|
|
4
|
+
export interface OAuthErrorResponse {
|
|
5
|
+
status: number;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
body: {
|
|
8
|
+
error: string;
|
|
9
|
+
error_description: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** RFC 6749 section 5.2 error response. A 401 invalid_client names the Basic challenge. */
|
|
13
|
+
export declare function buildOAuthErrorResponse(error: OAuthProtocolError, realm?: string): OAuthErrorResponse;
|
|
14
|
+
/**
|
|
15
|
+
* RFC 6749 section 4.1.2 / RFC 9207 authorization response URL. Always carries `iss` so a client
|
|
16
|
+
* talking to several servers can reject a mixed-up response.
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildAuthorizationResponseUrl(redirectUri: string, parameters: Readonly<Record<string, string>>, issuer: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Headers every authorization-server response should carry. */
|
|
2
|
+
export const OAUTH_RESPONSE_HEADERS = {
|
|
3
|
+
'Cache-Control': 'no-store',
|
|
4
|
+
Pragma: 'no-cache',
|
|
5
|
+
'Referrer-Policy': 'no-referrer',
|
|
6
|
+
'X-Robots-Tag': 'noindex, nofollow',
|
|
7
|
+
};
|
|
8
|
+
/** RFC 6749 section 5.2 error response. A 401 invalid_client names the Basic challenge. */
|
|
9
|
+
export function buildOAuthErrorResponse(error, realm = 'token') {
|
|
10
|
+
return {
|
|
11
|
+
status: error.status,
|
|
12
|
+
headers: {
|
|
13
|
+
...OAUTH_RESPONSE_HEADERS,
|
|
14
|
+
...(error.code === 'invalid_client' ? { 'WWW-Authenticate': `Basic realm="${realm}"` } : {}),
|
|
15
|
+
},
|
|
16
|
+
body: { error: error.code, error_description: error.message },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* RFC 6749 section 4.1.2 / RFC 9207 authorization response URL. Always carries `iss` so a client
|
|
21
|
+
* talking to several servers can reject a mixed-up response.
|
|
22
|
+
*/
|
|
23
|
+
export function buildAuthorizationResponseUrl(redirectUri, parameters, issuer) {
|
|
24
|
+
const redirect = new URL(redirectUri);
|
|
25
|
+
for (const [name, value] of Object.entries(parameters))
|
|
26
|
+
redirect.searchParams.set(name, value);
|
|
27
|
+
redirect.searchParams.set('iss', issuer);
|
|
28
|
+
return redirect.toString();
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ClientCredentials } from './request.mts';
|
|
2
|
+
import type { ClientStore, GrantStore, HashSecret } from './types.mts';
|
|
3
|
+
/**
|
|
4
|
+
* RFC 7009 revocation. Authenticates the client, then asks the store to revoke the token under
|
|
5
|
+
* both secret purposes because the token type is not known from the hash. Unknown tokens are not an
|
|
6
|
+
* error, so callers answer 200 on success.
|
|
7
|
+
*/
|
|
8
|
+
export declare function revokeToken(form: URLSearchParams, credentials: ClientCredentials, deps: {
|
|
9
|
+
clients: ClientStore;
|
|
10
|
+
grants: Pick<GrantStore, 'revokeToken'>;
|
|
11
|
+
hashSecret: HashSecret;
|
|
12
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { authenticateClient } from './client-auth.mjs';
|
|
2
|
+
import { requiredFormValue } from './request.mjs';
|
|
3
|
+
/**
|
|
4
|
+
* RFC 7009 revocation. Authenticates the client, then asks the store to revoke the token under
|
|
5
|
+
* both secret purposes because the token type is not known from the hash. Unknown tokens are not an
|
|
6
|
+
* error, so callers answer 200 on success.
|
|
7
|
+
*/
|
|
8
|
+
export async function revokeToken(form, credentials, deps) {
|
|
9
|
+
const client = await authenticateClient(deps.clients, credentials, deps.hashSecret);
|
|
10
|
+
const token = requiredFormValue(form, 'token');
|
|
11
|
+
await deps.grants.revokeToken(deps.hashSecret('refresh-token', token), client.clientId);
|
|
12
|
+
await deps.grants.revokeToken(deps.hashSecret('access-token', token), client.clientId);
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ProtectedResource, ResolveResource, ScopePolicy } from './types.mts';
|
|
2
|
+
/** Parses a space-delimited scope parameter against the caller's scope catalog. */
|
|
3
|
+
export declare function parseScopeParameter(value: unknown, policy: ScopePolicy): {
|
|
4
|
+
scopes: string[];
|
|
5
|
+
audiences: string[];
|
|
6
|
+
};
|
|
7
|
+
/** RFC 8707: accepts exactly one of the canonical resources the server publishes. */
|
|
8
|
+
export declare function resolveRequiredResource(value: unknown, resolveResource: ResolveResource): ProtectedResource;
|
|
9
|
+
export declare function assertScopesMatchResource(audiences: readonly string[], resource: ProtectedResource): void;
|
|
10
|
+
/** RFC 8707 makes `resource` optional at the token endpoint; when present it must match the grant. */
|
|
11
|
+
export declare function assertTokenRequestResource(requested: string | undefined, bound: string): void;
|
|
12
|
+
/**
|
|
13
|
+
* True when every requested scope is covered by the granted scopes. Unknown scopes (for example a
|
|
14
|
+
* scope removed from the catalog after registration) never cover anything.
|
|
15
|
+
*/
|
|
16
|
+
export declare function scopesCover(policy: ScopePolicy, granted: readonly string[], requested: readonly string[]): boolean;
|
|
17
|
+
export declare function assertClientMayRequestScopes(policy: ScopePolicy, clientScopes: readonly string[], requested: readonly string[]): void;
|
package/dist/scopes.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { OAuthProtocolError, invalidTarget } from './errors.mjs';
|
|
2
|
+
/** Parses a space-delimited scope parameter against the caller's scope catalog. */
|
|
3
|
+
export function parseScopeParameter(value, policy) {
|
|
4
|
+
if (typeof value !== 'string')
|
|
5
|
+
throw new OAuthProtocolError('invalid_scope', 'scope is required');
|
|
6
|
+
const result = policy.catalog.validateScopeSet(value.split(' ').filter(Boolean), {
|
|
7
|
+
surface: policy.surface,
|
|
8
|
+
allowMixedAudiences: true,
|
|
9
|
+
});
|
|
10
|
+
if (!result.valid) {
|
|
11
|
+
throw new OAuthProtocolError('invalid_scope', `invalid scope set: ${result.code}`);
|
|
12
|
+
}
|
|
13
|
+
return { scopes: result.scopes, audiences: result.audiences };
|
|
14
|
+
}
|
|
15
|
+
/** RFC 8707: accepts exactly one of the canonical resources the server publishes. */
|
|
16
|
+
export function resolveRequiredResource(value, resolveResource) {
|
|
17
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
18
|
+
throw new OAuthProtocolError('invalid_request', 'resource is required');
|
|
19
|
+
}
|
|
20
|
+
const resource = resolveResource(value);
|
|
21
|
+
if (!resource)
|
|
22
|
+
throw invalidTarget();
|
|
23
|
+
return resource;
|
|
24
|
+
}
|
|
25
|
+
export function assertScopesMatchResource(audiences, resource) {
|
|
26
|
+
if (!audiences.every((audience) => audience === resource.audience)) {
|
|
27
|
+
throw new OAuthProtocolError('invalid_scope', 'scope does not match the requested resource');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** RFC 8707 makes `resource` optional at the token endpoint; when present it must match the grant. */
|
|
31
|
+
export function assertTokenRequestResource(requested, bound) {
|
|
32
|
+
if (requested !== undefined && requested !== bound)
|
|
33
|
+
throw invalidTarget();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* True when every requested scope is covered by the granted scopes. Unknown scopes (for example a
|
|
37
|
+
* scope removed from the catalog after registration) never cover anything.
|
|
38
|
+
*/
|
|
39
|
+
export function scopesCover(policy, granted, requested) {
|
|
40
|
+
try {
|
|
41
|
+
return policy.catalog.hasEveryScope(granted, requested);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err instanceof RangeError)
|
|
45
|
+
return false;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function assertClientMayRequestScopes(policy, clientScopes, requested) {
|
|
50
|
+
if (!scopesCover(policy, clientScopes, requested)) {
|
|
51
|
+
throw new OAuthProtocolError('invalid_scope', 'requested scope is not registered for this client');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Opaque URL-safe random value with a caller-chosen prefix. */
|
|
2
|
+
export declare function generateOpaqueToken(prefix: string, bytes?: number): string;
|
|
3
|
+
/** Constant-time string equality. Digesting first hides length differences from the comparison. */
|
|
4
|
+
export declare function safeEqual(left: string, right: string): boolean;
|
package/dist/secrets.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** Opaque URL-safe random value with a caller-chosen prefix. */
|
|
3
|
+
export function generateOpaqueToken(prefix, bytes = 32) {
|
|
4
|
+
return `${prefix}${randomBytes(bytes).toString('base64url')}`;
|
|
5
|
+
}
|
|
6
|
+
/** Constant-time string equality. Digesting first hides length differences from the comparison. */
|
|
7
|
+
export function safeEqual(left, right) {
|
|
8
|
+
const a = createHash('sha256').update(left).digest();
|
|
9
|
+
const b = createHash('sha256').update(right).digest();
|
|
10
|
+
return timingSafeEqual(a, b);
|
|
11
|
+
}
|
package/dist/token.d.mts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ClientCredentials } from './request.mts';
|
|
2
|
+
import type { ClientStore, GrantStore, HashSecret, ScopePolicy, TokenLifetimes, TokenPrefixes, TokenResponse } from './types.mts';
|
|
3
|
+
export interface TokenDependencies {
|
|
4
|
+
clients: ClientStore;
|
|
5
|
+
grants: GrantStore;
|
|
6
|
+
scopes: ScopePolicy;
|
|
7
|
+
hashSecret: HashSecret;
|
|
8
|
+
lifetimes?: Partial<TokenLifetimes>;
|
|
9
|
+
prefixes?: Partial<TokenPrefixes>;
|
|
10
|
+
now?: () => Date;
|
|
11
|
+
/** Generates refresh family ids. Defaults to `crypto.randomUUID`. */
|
|
12
|
+
newFamilyId?: () => string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Handles the token endpoint for `authorization_code` and `refresh_token` grants: authenticates the
|
|
16
|
+
* client, then verifies the grant inside the store's redeem transaction. Codes and refresh tokens are
|
|
17
|
+
* single use; the adapter owns the transaction and decides what a failed redemption spends.
|
|
18
|
+
*/
|
|
19
|
+
export declare function exchangeTokenGrant(form: URLSearchParams, credentials: ClientCredentials, deps: TokenDependencies): Promise<TokenResponse>;
|
package/dist/token.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { authenticateClient } from './client-auth.mjs';
|
|
2
|
+
import { OAuthProtocolError, invalidGrant } from './errors.mjs';
|
|
3
|
+
import { DEFAULT_TOKEN_LIFETIMES, DEFAULT_TOKEN_PREFIXES } from './lifetimes.mjs';
|
|
4
|
+
import { validatePkceVerifier, verifyPkce } from './pkce.mjs';
|
|
5
|
+
import { requiredFormValue } from './request.mjs';
|
|
6
|
+
import { assertTokenRequestResource, parseScopeParameter, scopesCover } from './scopes.mjs';
|
|
7
|
+
import { generateOpaqueToken } from './secrets.mjs';
|
|
8
|
+
/**
|
|
9
|
+
* Handles the token endpoint for `authorization_code` and `refresh_token` grants: authenticates the
|
|
10
|
+
* client, then verifies the grant inside the store's redeem transaction. Codes and refresh tokens are
|
|
11
|
+
* single use; the adapter owns the transaction and decides what a failed redemption spends.
|
|
12
|
+
*/
|
|
13
|
+
export async function exchangeTokenGrant(form, credentials, deps) {
|
|
14
|
+
const client = await authenticateClient(deps.clients, credentials, deps.hashSecret);
|
|
15
|
+
const grantType = form.get('grant_type');
|
|
16
|
+
const resource = form.has('resource') ? requiredFormValue(form, 'resource') : undefined;
|
|
17
|
+
if (grantType === 'authorization_code') {
|
|
18
|
+
return exchangeAuthorizationCode(form, client, resource, deps);
|
|
19
|
+
}
|
|
20
|
+
if (grantType === 'refresh_token')
|
|
21
|
+
return exchangeRefreshToken(form, client, resource, deps);
|
|
22
|
+
throw new OAuthProtocolError('unsupported_grant_type', 'grant_type is not supported');
|
|
23
|
+
}
|
|
24
|
+
async function exchangeAuthorizationCode(form, client, resource, deps) {
|
|
25
|
+
const code = requiredFormValue(form, 'code');
|
|
26
|
+
const redirectUri = requiredFormValue(form, 'redirect_uri');
|
|
27
|
+
const verifier = validatePkceVerifier(form.get('code_verifier'));
|
|
28
|
+
let response;
|
|
29
|
+
await deps.grants.redeemAuthorizationCode(deps.hashSecret('authorization-code', code), (record) => {
|
|
30
|
+
const now = (deps.now?.() ?? new Date()).getTime();
|
|
31
|
+
assertCodeUsable(record, client, redirectUri, verifier, now);
|
|
32
|
+
assertTokenRequestResource(resource, record.resource);
|
|
33
|
+
const lifetimes = { ...DEFAULT_TOKEN_LIFETIMES, ...deps.lifetimes };
|
|
34
|
+
const issued = buildTokens({
|
|
35
|
+
client,
|
|
36
|
+
subject: record,
|
|
37
|
+
familyId: (deps.newFamilyId ?? crypto.randomUUID.bind(crypto))(),
|
|
38
|
+
generation: 0,
|
|
39
|
+
refreshExpiresAt: new Date(now + lifetimes.refreshTokenMs),
|
|
40
|
+
now,
|
|
41
|
+
deps,
|
|
42
|
+
});
|
|
43
|
+
response = issued.response;
|
|
44
|
+
return issued.tokens;
|
|
45
|
+
});
|
|
46
|
+
return response;
|
|
47
|
+
}
|
|
48
|
+
function assertCodeUsable(record, client, redirectUri, verifier, now) {
|
|
49
|
+
if (!record ||
|
|
50
|
+
record.replayed ||
|
|
51
|
+
record.clientId !== client.clientId ||
|
|
52
|
+
record.expiresAt.getTime() <= now ||
|
|
53
|
+
record.redirectUri !== redirectUri ||
|
|
54
|
+
!verifyPkce(record.codeChallenge, verifier)) {
|
|
55
|
+
throw invalidGrant('authorization code is invalid');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function exchangeRefreshToken(form, client, resource, deps) {
|
|
59
|
+
const token = requiredFormValue(form, 'refresh_token');
|
|
60
|
+
const requestedScope = form.has('scope') ? requiredFormValue(form, 'scope') : undefined;
|
|
61
|
+
let response;
|
|
62
|
+
await deps.grants.redeemRefreshToken(deps.hashSecret('refresh-token', token), (record) => {
|
|
63
|
+
const now = (deps.now?.() ?? new Date()).getTime();
|
|
64
|
+
if (!record ||
|
|
65
|
+
record.replayed ||
|
|
66
|
+
record.revoked ||
|
|
67
|
+
record.clientId !== client.clientId ||
|
|
68
|
+
record.expiresAt.getTime() <= now ||
|
|
69
|
+
record.familyExpiresAt.getTime() <= now) {
|
|
70
|
+
throw invalidGrant('refresh token is invalid');
|
|
71
|
+
}
|
|
72
|
+
assertTokenRequestResource(resource, record.resource);
|
|
73
|
+
let scopes = record.scopes;
|
|
74
|
+
if (requestedScope !== undefined) {
|
|
75
|
+
const requested = parseScopeParameter(requestedScope, deps.scopes).scopes;
|
|
76
|
+
if (!scopesCover(deps.scopes, record.scopes, requested)) {
|
|
77
|
+
throw new OAuthProtocolError('invalid_scope', 'scope exceeds the original grant');
|
|
78
|
+
}
|
|
79
|
+
scopes = requested;
|
|
80
|
+
}
|
|
81
|
+
const issued = buildTokens({
|
|
82
|
+
client,
|
|
83
|
+
subject: { ...record, scopes },
|
|
84
|
+
familyId: record.familyId,
|
|
85
|
+
generation: record.generation + 1,
|
|
86
|
+
refreshExpiresAt: record.familyExpiresAt,
|
|
87
|
+
now,
|
|
88
|
+
deps,
|
|
89
|
+
});
|
|
90
|
+
response = issued.response;
|
|
91
|
+
return issued.tokens;
|
|
92
|
+
});
|
|
93
|
+
return response;
|
|
94
|
+
}
|
|
95
|
+
function buildTokens(input) {
|
|
96
|
+
const { client, subject, deps, now } = input;
|
|
97
|
+
const prefixes = { ...DEFAULT_TOKEN_PREFIXES, ...deps.prefixes };
|
|
98
|
+
const lifetimes = { ...DEFAULT_TOKEN_LIFETIMES, ...deps.lifetimes };
|
|
99
|
+
const accessToken = generateOpaqueToken(prefixes.accessToken);
|
|
100
|
+
const refreshToken = generateOpaqueToken(prefixes.refreshToken);
|
|
101
|
+
// An access token never outlives the refresh family that authorizes it.
|
|
102
|
+
const accessExpiresAt = new Date(Math.min(now + lifetimes.accessTokenMs, input.refreshExpiresAt.getTime()));
|
|
103
|
+
const tokens = {
|
|
104
|
+
clientId: client.clientId,
|
|
105
|
+
grantId: subject.grantId,
|
|
106
|
+
subject: subject.subject,
|
|
107
|
+
familyId: input.familyId,
|
|
108
|
+
generation: input.generation,
|
|
109
|
+
resource: subject.resource,
|
|
110
|
+
scopes: subject.scopes,
|
|
111
|
+
accessTokenHash: deps.hashSecret('access-token', accessToken),
|
|
112
|
+
accessExpiresAt,
|
|
113
|
+
refreshTokenHash: deps.hashSecret('refresh-token', refreshToken),
|
|
114
|
+
refreshExpiresAt: input.refreshExpiresAt,
|
|
115
|
+
};
|
|
116
|
+
const response = {
|
|
117
|
+
access_token: accessToken,
|
|
118
|
+
expires_in: Math.max(0, Math.floor((accessExpiresAt.getTime() - now) / 1000)),
|
|
119
|
+
refresh_token: refreshToken,
|
|
120
|
+
scope: subject.scopes.join(' '),
|
|
121
|
+
token_type: 'Bearer',
|
|
122
|
+
};
|
|
123
|
+
return { tokens, response };
|
|
124
|
+
}
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ScopeCatalog } from '@vouchington/utils/scopes';
|
|
2
|
+
export type ClientAuthMethod = 'none' | 'client_secret_basic';
|
|
3
|
+
/** Distinct purposes let the application derive independent hashes for each secret kind. */
|
|
4
|
+
export type SecretPurpose = 'client-secret' | 'access-token' | 'refresh-token' | 'authorization-code';
|
|
5
|
+
/** Application-supplied one-way hash (for example a keyed HMAC) used for every stored secret. */
|
|
6
|
+
export type HashSecret = (purpose: SecretPurpose, value: string) => string;
|
|
7
|
+
export interface ScopePolicy {
|
|
8
|
+
catalog: ScopeCatalog;
|
|
9
|
+
/** Catalog surface that OAuth clients may request, for example "oauth". */
|
|
10
|
+
surface: string;
|
|
11
|
+
}
|
|
12
|
+
/** RFC 8707 protected resource. `audience` is matched against the scope catalog audiences. */
|
|
13
|
+
export interface ProtectedResource {
|
|
14
|
+
url: string;
|
|
15
|
+
audience: string;
|
|
16
|
+
}
|
|
17
|
+
export type ResolveResource = (value: string) => ProtectedResource | null;
|
|
18
|
+
export interface OAuthClient {
|
|
19
|
+
clientId: string;
|
|
20
|
+
clientName: string;
|
|
21
|
+
redirectUris: readonly string[];
|
|
22
|
+
scopes: readonly string[];
|
|
23
|
+
tokenEndpointAuthMethod: ClientAuthMethod;
|
|
24
|
+
/** Hash produced by `HashSecret('client-secret', ...)`; null for public clients. */
|
|
25
|
+
clientSecretHash: string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface NewOAuthClient extends OAuthClient {
|
|
28
|
+
grantTypes: string[];
|
|
29
|
+
responseTypes: string[];
|
|
30
|
+
issuedAt: Date;
|
|
31
|
+
}
|
|
32
|
+
/** Returns only clients that may currently authorize; revoked or ineligible clients are null. */
|
|
33
|
+
export interface ClientStore {
|
|
34
|
+
findClient(clientId: string): Promise<OAuthClient | null>;
|
|
35
|
+
}
|
|
36
|
+
export interface ClientRegistry extends ClientStore {
|
|
37
|
+
/** `context` is the opaque value the application passed to `registerClient`, such as an owner. */
|
|
38
|
+
createClient(client: NewOAuthClient, context?: unknown): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export interface AuthorizationCodeRecord {
|
|
41
|
+
clientId: string;
|
|
42
|
+
redirectUri: string;
|
|
43
|
+
resource: string;
|
|
44
|
+
scopes: readonly string[];
|
|
45
|
+
codeChallenge: string;
|
|
46
|
+
grantId: string;
|
|
47
|
+
subject: string;
|
|
48
|
+
expiresAt: Date;
|
|
49
|
+
/** True when the code had already been consumed before this call. */
|
|
50
|
+
replayed: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface RefreshTokenRecord {
|
|
53
|
+
clientId: string;
|
|
54
|
+
grantId: string;
|
|
55
|
+
subject: string;
|
|
56
|
+
familyId: string;
|
|
57
|
+
generation: number;
|
|
58
|
+
resource: string;
|
|
59
|
+
scopes: readonly string[];
|
|
60
|
+
/** Expiry of the whole refresh family; rotated refresh tokens never extend it. */
|
|
61
|
+
familyExpiresAt: Date;
|
|
62
|
+
expiresAt: Date;
|
|
63
|
+
revoked: boolean;
|
|
64
|
+
/** True when the token had already been rotated before this call. */
|
|
65
|
+
replayed: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface IssuedTokens {
|
|
68
|
+
clientId: string;
|
|
69
|
+
grantId: string;
|
|
70
|
+
subject: string;
|
|
71
|
+
familyId: string;
|
|
72
|
+
generation: number;
|
|
73
|
+
resource: string;
|
|
74
|
+
scopes: readonly string[];
|
|
75
|
+
accessTokenHash: string;
|
|
76
|
+
accessExpiresAt: Date;
|
|
77
|
+
refreshTokenHash: string;
|
|
78
|
+
refreshExpiresAt: Date;
|
|
79
|
+
}
|
|
80
|
+
export interface AuthorizationCodeGrant {
|
|
81
|
+
codeHash: string;
|
|
82
|
+
clientId: string;
|
|
83
|
+
redirectUri: string;
|
|
84
|
+
resource: string;
|
|
85
|
+
scopes: readonly string[];
|
|
86
|
+
codeChallenge: string;
|
|
87
|
+
grantId: string;
|
|
88
|
+
subject: string;
|
|
89
|
+
expiresAt: Date;
|
|
90
|
+
}
|
|
91
|
+
export interface GrantStore {
|
|
92
|
+
saveAuthorizationCode(code: AuthorizationCodeGrant): Promise<void>;
|
|
93
|
+
/**
|
|
94
|
+
* Looks up the code and calls `redeem` with the record (or null when unknown) inside the
|
|
95
|
+
* adapter's own transaction, holding a lock so concurrent exchanges cannot both succeed. If
|
|
96
|
+
* `redeem` returns tokens, the adapter must persist them and mark the code spent atomically. If
|
|
97
|
+
* it throws, no tokens may be persisted and the error must propagate; the adapter decides whether
|
|
98
|
+
* a failed attempt spends the code. An already-spent code is passed with `replayed: true` so the
|
|
99
|
+
* adapter can revoke what it issued.
|
|
100
|
+
*/
|
|
101
|
+
redeemAuthorizationCode(codeHash: string, redeem: (record: AuthorizationCodeRecord | null) => IssuedTokens | Promise<IssuedTokens>): Promise<void>;
|
|
102
|
+
/** Same contract for refresh tokens: persist the rotated pair and spend the old token atomically. */
|
|
103
|
+
redeemRefreshToken(tokenHash: string, redeem: (record: RefreshTokenRecord | null) => IssuedTokens | Promise<IssuedTokens>): Promise<void>;
|
|
104
|
+
/** Revoke the access or refresh token (and its family) when it belongs to `clientId`. */
|
|
105
|
+
revokeToken(tokenHash: string, clientId: string): Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
export interface TokenResponse {
|
|
108
|
+
access_token: string;
|
|
109
|
+
expires_in: number;
|
|
110
|
+
refresh_token: string;
|
|
111
|
+
scope: string;
|
|
112
|
+
token_type: 'Bearer';
|
|
113
|
+
}
|
|
114
|
+
export interface TokenLifetimes {
|
|
115
|
+
authorizationCodeMs: number;
|
|
116
|
+
accessTokenMs: number;
|
|
117
|
+
refreshTokenMs: number;
|
|
118
|
+
}
|
|
119
|
+
export interface TokenPrefixes {
|
|
120
|
+
authorizationCode: string;
|
|
121
|
+
accessToken: string;
|
|
122
|
+
refreshToken: string;
|
|
123
|
+
}
|
package/dist/types.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/oauth-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generic OAuth 2.0 authorization-server building blocks with injected stores (RFC 6749, 7591, 7636, 8414).",
|
|
5
|
+
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/oauth-server#readme",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jonathan Ong",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vouchington/vouchington-platform.git",
|
|
11
|
+
"directory": "packages/oauth-server"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.mjs",
|
|
20
|
+
"types": "./dist/index.d.mts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"import": "./dist/index.mjs",
|
|
25
|
+
"default": "./dist/index.mjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@vouchington/utils": "^0.7.0"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=24.0.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc --project tsconfig.build.json"
|
|
39
|
+
}
|
|
6
40
|
}
|