@nestjs/authentication 0.0.1
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 +23 -0
- package/README.md +50 -0
- package/dist/account/email-verification.handler.d.ts +23 -0
- package/dist/account/email-verification.handler.js +17 -0
- package/dist/account/email-verification.service.d.ts +46 -0
- package/dist/account/email-verification.service.js +111 -0
- package/dist/account/email.util.d.ts +14 -0
- package/dist/account/email.util.js +28 -0
- package/dist/account/in-memory-email-token.store.d.ts +12 -0
- package/dist/account/in-memory-email-token.store.js +60 -0
- package/dist/account/password-reset.handler.d.ts +19 -0
- package/dist/account/password-reset.handler.js +9 -0
- package/dist/account/password-reset.service.d.ts +69 -0
- package/dist/account/password-reset.service.js +204 -0
- package/dist/authentication.constants.d.ts +13 -0
- package/dist/authentication.constants.js +13 -0
- package/dist/authentication.module-definition.d.ts +21 -0
- package/dist/authentication.module-definition.js +147 -0
- package/dist/authentication.module.d.ts +34 -0
- package/dist/authentication.module.js +139 -0
- package/dist/context/authentication-scope.service.d.ts +39 -0
- package/dist/context/authentication-scope.service.js +64 -0
- package/dist/context/authentication.context.d.ts +28 -0
- package/dist/context/authentication.context.js +58 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/decorators/authenticate.decorator.d.ts +10 -0
- package/dist/decorators/authenticate.decorator.js +18 -0
- package/dist/decorators/current-session.decorator.d.ts +2 -0
- package/dist/decorators/current-session.decorator.js +6 -0
- package/dist/decorators/current-user.decorator.d.ts +6 -0
- package/dist/decorators/current-user.decorator.js +11 -0
- package/dist/decorators/index.d.ts +4 -0
- package/dist/decorators/index.js +4 -0
- package/dist/decorators/public.decorator.d.ts +5 -0
- package/dist/decorators/public.decorator.js +6 -0
- package/dist/errors/authentication.error.d.ts +27 -0
- package/dist/errors/authentication.error.js +33 -0
- package/dist/errors/index.d.ts +7 -0
- package/dist/errors/index.js +7 -0
- package/dist/errors/jwt.error.d.ts +9 -0
- package/dist/errors/jwt.error.js +12 -0
- package/dist/errors/magic-link.error.d.ts +18 -0
- package/dist/errors/magic-link.error.js +21 -0
- package/dist/errors/mfa-already-enrolled.error.d.ts +14 -0
- package/dist/errors/mfa-already-enrolled.error.js +17 -0
- package/dist/errors/oidc.error.d.ts +15 -0
- package/dist/errors/oidc.error.js +13 -0
- package/dist/errors/refresh-token.error.d.ts +10 -0
- package/dist/errors/refresh-token.error.js +12 -0
- package/dist/errors/secret-decryption.error.d.ts +3 -0
- package/dist/errors/secret-decryption.error.js +6 -0
- package/dist/events/authentication-events.interface.d.ts +121 -0
- package/dist/events/authentication-events.interface.js +1 -0
- package/dist/events/authentication-events.service.d.ts +19 -0
- package/dist/events/authentication-events.service.js +39 -0
- package/dist/events/authentication.channels.d.ts +4 -0
- package/dist/events/authentication.channels.js +11 -0
- package/dist/events/index.d.ts +3 -0
- package/dist/events/index.js +3 -0
- package/dist/guards/authentication.guard.d.ts +57 -0
- package/dist/guards/authentication.guard.js +171 -0
- package/dist/guards/index.d.ts +1 -0
- package/dist/guards/index.js +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +52 -0
- package/dist/interceptors/authentication-scope.interceptor.d.ts +20 -0
- package/dist/interceptors/authentication-scope.interceptor.js +48 -0
- package/dist/interceptors/index.d.ts +1 -0
- package/dist/interceptors/index.js +1 -0
- package/dist/interfaces/api-key.interface.d.ts +41 -0
- package/dist/interfaces/api-key.interface.js +1 -0
- package/dist/interfaces/authenticate-options.interface.d.ts +31 -0
- package/dist/interfaces/authenticate-options.interface.js +1 -0
- package/dist/interfaces/authentication-error-options.interface.d.ts +8 -0
- package/dist/interfaces/authentication-error-options.interface.js +1 -0
- package/dist/interfaces/authentication-module-options.interface.d.ts +58 -0
- package/dist/interfaces/authentication-module-options.interface.js +1 -0
- package/dist/interfaces/authentication-registry.interface.d.ts +41 -0
- package/dist/interfaces/authentication-registry.interface.js +1 -0
- package/dist/interfaces/authentication-result.interface.d.ts +25 -0
- package/dist/interfaces/authentication-result.interface.js +1 -0
- package/dist/interfaces/authentication-storage.interface.d.ts +29 -0
- package/dist/interfaces/authentication-storage.interface.js +1 -0
- package/dist/interfaces/duration.interface.d.ts +2 -0
- package/dist/interfaces/duration.interface.js +1 -0
- package/dist/interfaces/email-token-store.interface.d.ts +52 -0
- package/dist/interfaces/email-token-store.interface.js +1 -0
- package/dist/interfaces/email-verification.interface.d.ts +18 -0
- package/dist/interfaces/email-verification.interface.js +1 -0
- package/dist/interfaces/index.d.ts +27 -0
- package/dist/interfaces/index.js +27 -0
- package/dist/interfaces/jwt-options.interface.d.ts +68 -0
- package/dist/interfaces/jwt-options.interface.js +1 -0
- package/dist/interfaces/jwt.interface.d.ts +36 -0
- package/dist/interfaces/jwt.interface.js +1 -0
- package/dist/interfaces/magic-link-store.interface.d.ts +36 -0
- package/dist/interfaces/magic-link-store.interface.js +1 -0
- package/dist/interfaces/magic-link.interface.d.ts +55 -0
- package/dist/interfaces/magic-link.interface.js +1 -0
- package/dist/interfaces/mfa-options.interface.d.ts +44 -0
- package/dist/interfaces/mfa-options.interface.js +1 -0
- package/dist/interfaces/mfa-store.interface.d.ts +70 -0
- package/dist/interfaces/mfa-store.interface.js +1 -0
- package/dist/interfaces/oidc-provider.interface.d.ts +56 -0
- package/dist/interfaces/oidc-provider.interface.js +1 -0
- package/dist/interfaces/oidc-state-store.interface.d.ts +25 -0
- package/dist/interfaces/oidc-state-store.interface.js +1 -0
- package/dist/interfaces/oidc.interface.d.ts +84 -0
- package/dist/interfaces/oidc.interface.js +1 -0
- package/dist/interfaces/password-hasher-options.interface.d.ts +17 -0
- package/dist/interfaces/password-hasher-options.interface.js +1 -0
- package/dist/interfaces/password-reset.interface.d.ts +49 -0
- package/dist/interfaces/password-reset.interface.js +1 -0
- package/dist/interfaces/refresh-token-options.interface.d.ts +10 -0
- package/dist/interfaces/refresh-token-options.interface.js +1 -0
- package/dist/interfaces/refresh-token-store.interface.d.ts +61 -0
- package/dist/interfaces/refresh-token-store.interface.js +1 -0
- package/dist/interfaces/session-store.interface.d.ts +53 -0
- package/dist/interfaces/session-store.interface.js +1 -0
- package/dist/interfaces/session.interface.d.ts +50 -0
- package/dist/interfaces/session.interface.js +1 -0
- package/dist/interfaces/sign-in-options.interface.d.ts +9 -0
- package/dist/interfaces/sign-in-options.interface.js +1 -0
- package/dist/interfaces/token.interface.d.ts +30 -0
- package/dist/interfaces/token.interface.js +1 -0
- package/dist/jwt/amr.util.d.ts +9 -0
- package/dist/jwt/amr.util.js +11 -0
- package/dist/jwt/in-memory-refresh-token.store.d.ts +29 -0
- package/dist/jwt/in-memory-refresh-token.store.js +67 -0
- package/dist/jwt/jwks.client.d.ts +34 -0
- package/dist/jwt/jwks.client.js +154 -0
- package/dist/jwt/jws.util.d.ts +45 -0
- package/dist/jwt/jws.util.js +282 -0
- package/dist/jwt/jwt-bearer.provider.d.ts +48 -0
- package/dist/jwt/jwt-bearer.provider.js +126 -0
- package/dist/jwt/jwt-signer.service.d.ts +19 -0
- package/dist/jwt/jwt-signer.service.js +36 -0
- package/dist/jwt/jwt-verifier.service.d.ts +22 -0
- package/dist/jwt/jwt-verifier.service.js +109 -0
- package/dist/jwt/token.service.d.ts +81 -0
- package/dist/jwt/token.service.js +230 -0
- package/dist/magic-link/in-memory-magic-link.store.d.ts +12 -0
- package/dist/magic-link/in-memory-magic-link.store.js +45 -0
- package/dist/magic-link/magic-link.handler.d.ts +25 -0
- package/dist/magic-link/magic-link.handler.js +14 -0
- package/dist/magic-link/magic-link.service.d.ts +79 -0
- package/dist/magic-link/magic-link.service.js +215 -0
- package/dist/mfa/in-memory-mfa.store.d.ts +25 -0
- package/dist/mfa/in-memory-mfa.store.js +73 -0
- package/dist/mfa/mfa.service.d.ts +104 -0
- package/dist/mfa/mfa.service.js +346 -0
- package/dist/mfa/otp.util.d.ts +7 -0
- package/dist/mfa/otp.util.js +52 -0
- package/dist/mfa/secret-cipher.service.d.ts +28 -0
- package/dist/mfa/secret-cipher.service.js +106 -0
- package/dist/oidc/in-memory-oidc-state.store.d.ts +13 -0
- package/dist/oidc/in-memory-oidc-state.store.js +45 -0
- package/dist/oidc/oidc-account.resolver.d.ts +23 -0
- package/dist/oidc/oidc-account.resolver.js +16 -0
- package/dist/oidc/oidc.client.d.ts +44 -0
- package/dist/oidc/oidc.client.js +286 -0
- package/dist/oidc/oidc.presets.d.ts +24 -0
- package/dist/oidc/oidc.presets.js +53 -0
- package/dist/oidc/oidc.service.d.ts +88 -0
- package/dist/oidc/oidc.service.js +259 -0
- package/dist/providers/api-key.provider.d.ts +51 -0
- package/dist/providers/api-key.provider.js +93 -0
- package/dist/providers/authentication.provider.d.ts +40 -0
- package/dist/providers/authentication.provider.js +33 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.js +2 -0
- package/dist/services/authentication-registry.service.d.ts +75 -0
- package/dist/services/authentication-registry.service.js +235 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +3 -0
- package/dist/services/password-hasher.service.d.ts +47 -0
- package/dist/services/password-hasher.service.js +170 -0
- package/dist/services/ws-authenticator.service.d.ts +38 -0
- package/dist/services/ws-authenticator.service.js +92 -0
- package/dist/session/cookies.util.d.ts +34 -0
- package/dist/session/cookies.util.js +82 -0
- package/dist/session/in-memory-session.store.d.ts +33 -0
- package/dist/session/in-memory-session.store.js +60 -0
- package/dist/session/session-cookie.provider.d.ts +36 -0
- package/dist/session/session-cookie.provider.js +58 -0
- package/dist/session/session.service.d.ts +97 -0
- package/dist/session/session.service.js +208 -0
- package/dist/session/sign-in.service.d.ts +87 -0
- package/dist/session/sign-in.service.js +206 -0
- package/dist/storage/authentication.storage.d.ts +130 -0
- package/dist/storage/authentication.storage.js +350 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/testing/index.d.ts +39 -0
- package/dist/testing/index.js +531 -0
- package/dist/utils/api-key.util.d.ts +10 -0
- package/dist/utils/api-key.util.js +10 -0
- package/dist/utils/auth-state.util.d.ts +54 -0
- package/dist/utils/auth-state.util.js +209 -0
- package/dist/utils/bearer.util.d.ts +9 -0
- package/dist/utils/bearer.util.js +21 -0
- package/dist/utils/cross-origin.util.d.ts +29 -0
- package/dist/utils/cross-origin.util.js +90 -0
- package/dist/utils/crypto.util.d.ts +19 -0
- package/dist/utils/crypto.util.js +38 -0
- package/dist/utils/duration.util.d.ts +9 -0
- package/dist/utils/duration.util.js +44 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.js +8 -0
- package/dist/utils/options.util.d.ts +20 -0
- package/dist/utils/options.util.js +44 -0
- package/dist/utils/transport-errors.util.d.ts +33 -0
- package/dist/utils/transport-errors.util.js +70 -0
- package/package.json +108 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { OAuthTokens, OidcProfile, OidcProviderConfig } from '../interfaces/oidc-provider.interface.js';
|
|
2
|
+
import type { OidcTransaction, OidcClientOptions } from '../interfaces/oidc.interface.js';
|
|
3
|
+
/**
|
|
4
|
+
* One OAuth 2.0 / OpenID Connect provider: authorization code flow with
|
|
5
|
+
* PKCE (S256), `state` and `nonce`.
|
|
6
|
+
*
|
|
7
|
+
* Checks, in order: IdP error, `iss` response parameter (RFC 9207, mix-up
|
|
8
|
+
* defence), token response shape, ID token signature (JWKS, RS256/ES256,
|
|
9
|
+
* alg/key binding), `iss`, `aud`, `azp`, `exp`, `iat`, `nonce`, and that
|
|
10
|
+
* userinfo's `sub` matches the ID token's (OIDC Core §5.3.2).
|
|
11
|
+
*/
|
|
12
|
+
export declare class OidcClient {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
private readonly config;
|
|
15
|
+
private readonly options;
|
|
16
|
+
private discovery?;
|
|
17
|
+
private jwks?;
|
|
18
|
+
readonly kind: 'oidc' | 'oauth2';
|
|
19
|
+
constructor(name: string, config: OidcProviderConfig, options?: OidcClientOptions);
|
|
20
|
+
/**
|
|
21
|
+
* The provider's authorization URL, the transaction to store, and the
|
|
22
|
+
* browser binding for the transaction cookie. `state` is the SHA-256 of
|
|
23
|
+
* `binding`: the state travels in URLs (access logs, history, the error
|
|
24
|
+
* page of a failed callback), so it must not be what the cookie holds, or
|
|
25
|
+
* whoever reads a callback URL could redeem it from any client.
|
|
26
|
+
*/
|
|
27
|
+
authorizationRequest(redirectUri: string, redirectTo?: string, ttlMs?: number): Promise<{
|
|
28
|
+
url: string;
|
|
29
|
+
transaction: OidcTransaction;
|
|
30
|
+
binding: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** Completes the flow. The caller has already matched `state` to the browser and loaded `transaction`. */
|
|
33
|
+
callback(params: Record<string, unknown>, transaction: OidcTransaction, redirectUri: string): Promise<{
|
|
34
|
+
profile: OidcProfile;
|
|
35
|
+
tokens: OAuthTokens;
|
|
36
|
+
}>;
|
|
37
|
+
private verifyIdToken;
|
|
38
|
+
private exchange;
|
|
39
|
+
private endpoints;
|
|
40
|
+
private discover;
|
|
41
|
+
private getJson;
|
|
42
|
+
private request;
|
|
43
|
+
private now;
|
|
44
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { randomToken, safeEqual, sha256 } from '../utils/crypto.util.js';
|
|
3
|
+
import { durationOr } from '../utils/duration.util.js';
|
|
4
|
+
import { JwksClient } from '../jwt/jwks.client.js';
|
|
5
|
+
import { JwtError } from '../errors/jwt.error.js';
|
|
6
|
+
import { JwtVerifier } from '../jwt/jwt-verifier.service.js';
|
|
7
|
+
import { OidcError } from '../errors/oidc.error.js';
|
|
8
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
9
|
+
/**
|
|
10
|
+
* Only https, except loopback http (local IdPs and tests). The URLs come from the provider's
|
|
11
|
+
* discovery document or the app's configuration, never from the user: a bad one is a
|
|
12
|
+
* misconfiguration (`unavailable`, a 502), not a failed sign-in.
|
|
13
|
+
*/
|
|
14
|
+
function assertSecureUrl(value, what) {
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(value ?? '');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new OidcError(`${what} is not a URL`, 'unavailable');
|
|
21
|
+
}
|
|
22
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.has(url.hostname))) {
|
|
23
|
+
throw new OidcError(`${what} must use https`, 'unavailable');
|
|
24
|
+
}
|
|
25
|
+
return url.toString();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One OAuth 2.0 / OpenID Connect provider: authorization code flow with
|
|
29
|
+
* PKCE (S256), `state` and `nonce`.
|
|
30
|
+
*
|
|
31
|
+
* Checks, in order: IdP error, `iss` response parameter (RFC 9207, mix-up
|
|
32
|
+
* defence), token response shape, ID token signature (JWKS, RS256/ES256,
|
|
33
|
+
* alg/key binding), `iss`, `aud`, `azp`, `exp`, `iat`, `nonce`, and that
|
|
34
|
+
* userinfo's `sub` matches the ID token's (OIDC Core §5.3.2).
|
|
35
|
+
*/
|
|
36
|
+
export class OidcClient {
|
|
37
|
+
constructor(name, config, options = {}) {
|
|
38
|
+
this.name = name;
|
|
39
|
+
this.config = config;
|
|
40
|
+
this.options = options;
|
|
41
|
+
this.kind = config.kind ?? 'oidc';
|
|
42
|
+
if (this.kind === 'oidc' && !config.issuer) {
|
|
43
|
+
throw new Error(`OIDC provider '${name}' needs an issuer`);
|
|
44
|
+
}
|
|
45
|
+
if (this.kind === 'oauth2' && (!config.authorizationEndpoint || !config.tokenEndpoint || !config.profile)) {
|
|
46
|
+
throw new Error(`OAuth 2.0 provider '${name}' needs authorizationEndpoint, tokenEndpoint and profile()`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The provider's authorization URL, the transaction to store, and the
|
|
51
|
+
* browser binding for the transaction cookie. `state` is the SHA-256 of
|
|
52
|
+
* `binding`: the state travels in URLs (access logs, history, the error
|
|
53
|
+
* page of a failed callback), so it must not be what the cookie holds, or
|
|
54
|
+
* whoever reads a callback URL could redeem it from any client.
|
|
55
|
+
*/
|
|
56
|
+
async authorizationRequest(redirectUri, redirectTo, ttlMs = 600_000) {
|
|
57
|
+
const endpoints = await this.endpoints();
|
|
58
|
+
const now = this.now();
|
|
59
|
+
const binding = randomToken();
|
|
60
|
+
const transaction = {
|
|
61
|
+
state: sha256(binding),
|
|
62
|
+
provider: this.name,
|
|
63
|
+
codeVerifier: randomToken(),
|
|
64
|
+
...(this.kind === 'oidc' && { nonce: randomToken() }),
|
|
65
|
+
...(redirectTo && { redirectTo }),
|
|
66
|
+
createdAt: new Date(now),
|
|
67
|
+
expiresAt: new Date(now + ttlMs),
|
|
68
|
+
};
|
|
69
|
+
const url = new URL(endpoints.authorization_endpoint);
|
|
70
|
+
const params = {
|
|
71
|
+
...this.config.authorizationParams,
|
|
72
|
+
response_type: 'code',
|
|
73
|
+
client_id: this.config.clientId,
|
|
74
|
+
redirect_uri: redirectUri,
|
|
75
|
+
scope: (this.config.scopes ?? ['openid', 'email', 'profile']).join(' '),
|
|
76
|
+
state: transaction.state,
|
|
77
|
+
code_challenge: createHash('sha256').update(transaction.codeVerifier).digest('base64url'),
|
|
78
|
+
code_challenge_method: 'S256',
|
|
79
|
+
...(transaction.nonce && { nonce: transaction.nonce }),
|
|
80
|
+
};
|
|
81
|
+
for (const [key, value] of Object.entries(params)) {
|
|
82
|
+
url.searchParams.set(key, value);
|
|
83
|
+
}
|
|
84
|
+
return { url: url.toString(), transaction, binding };
|
|
85
|
+
}
|
|
86
|
+
/** Completes the flow. The caller has already matched `state` to the browser and loaded `transaction`. */
|
|
87
|
+
async callback(params, transaction, redirectUri) {
|
|
88
|
+
if (typeof params.error === 'string') {
|
|
89
|
+
throw new OidcError(`provider returned ${params.error}`, 'request');
|
|
90
|
+
}
|
|
91
|
+
if (typeof params.code !== 'string' || !params.code) {
|
|
92
|
+
throw new OidcError('missing code', 'request');
|
|
93
|
+
}
|
|
94
|
+
const endpoints = await this.endpoints();
|
|
95
|
+
if (this.kind === 'oidc') {
|
|
96
|
+
if (params.iss !== undefined && params.iss !== endpoints.issuer) {
|
|
97
|
+
throw new OidcError('issuer mismatch', 'request');
|
|
98
|
+
}
|
|
99
|
+
if (params.iss === undefined && endpoints.authorization_response_iss_parameter_supported) {
|
|
100
|
+
throw new OidcError('missing iss', 'request');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const tokens = await this.exchange(endpoints, params.code, transaction, redirectUri);
|
|
104
|
+
const context = { provider: this.name, tokens, fetchJson: (url) => this.getJson(url, tokens.accessToken) };
|
|
105
|
+
const userinfo = (url) => this.getJson(url, tokens.accessToken, 'userinfo endpoint');
|
|
106
|
+
if (this.kind === 'oauth2') {
|
|
107
|
+
const profile = endpoints.userinfo_endpoint ? await userinfo(endpoints.userinfo_endpoint) : {};
|
|
108
|
+
return { profile: await this.config.profile(profile, context), tokens };
|
|
109
|
+
}
|
|
110
|
+
if (!tokens.idToken) {
|
|
111
|
+
throw new OidcError('missing id_token', 'verification');
|
|
112
|
+
}
|
|
113
|
+
const claims = await this.verifyIdToken(endpoints, tokens.idToken, transaction.nonce);
|
|
114
|
+
let merged = claims;
|
|
115
|
+
let info;
|
|
116
|
+
if (endpoints.userinfo_endpoint) {
|
|
117
|
+
info = await userinfo(endpoints.userinfo_endpoint);
|
|
118
|
+
if (info?.sub !== claims.sub) {
|
|
119
|
+
throw new OidcError('userinfo subject mismatch', 'verification');
|
|
120
|
+
}
|
|
121
|
+
merged = { ...info, ...claims };
|
|
122
|
+
}
|
|
123
|
+
// The address and whether it is verified come as a pair, from one source: the ID token's, or
|
|
124
|
+
// else userinfo's. `email_verified` in one says nothing about an `email` in the other.
|
|
125
|
+
const addressed = typeof claims.email === 'string' ? claims : (info ?? {});
|
|
126
|
+
const email = typeof addressed.email === 'string' ? addressed.email : undefined;
|
|
127
|
+
return {
|
|
128
|
+
profile: {
|
|
129
|
+
provider: this.name,
|
|
130
|
+
subject: claims.sub,
|
|
131
|
+
email,
|
|
132
|
+
emailVerified: email !== undefined && addressed.email_verified === true,
|
|
133
|
+
name: typeof merged.name === 'string' ? merged.name : undefined,
|
|
134
|
+
picture: typeof merged.picture === 'string' ? merged.picture : undefined,
|
|
135
|
+
claims: merged,
|
|
136
|
+
},
|
|
137
|
+
tokens,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async verifyIdToken(endpoints, idToken, nonce) {
|
|
141
|
+
this.jwks ??= new JwksClient(assertSecureUrl(endpoints.jwks_uri, 'jwks_uri'), {
|
|
142
|
+
...this.options.jwks,
|
|
143
|
+
fetch: this.options.fetch,
|
|
144
|
+
now: this.options.now,
|
|
145
|
+
});
|
|
146
|
+
const verifier = new JwtVerifier({
|
|
147
|
+
jwks: this.jwks,
|
|
148
|
+
algorithms: this.config.idTokenAlgorithms ?? ['RS256', 'ES256'],
|
|
149
|
+
issuer: endpoints.issuer,
|
|
150
|
+
audience: this.config.clientId,
|
|
151
|
+
required: ['exp', 'iat', 'sub'],
|
|
152
|
+
clockTolerance: this.options.clockTolerance ?? '30s',
|
|
153
|
+
now: this.options.now,
|
|
154
|
+
});
|
|
155
|
+
let claims;
|
|
156
|
+
try {
|
|
157
|
+
claims = await verifier.verify(idToken);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error instanceof JwtError) {
|
|
161
|
+
throw new OidcError(`id_token ${error.message}`, 'verification');
|
|
162
|
+
}
|
|
163
|
+
// The provider's key set could not be loaded: an outage (502), as any other of the provider's.
|
|
164
|
+
throw new OidcError(error.message, 'unavailable');
|
|
165
|
+
}
|
|
166
|
+
if (typeof claims.sub !== 'string' || !claims.sub) {
|
|
167
|
+
throw new OidcError('id_token without subject', 'verification');
|
|
168
|
+
}
|
|
169
|
+
const audiences = [].concat(claims.aud);
|
|
170
|
+
if ((audiences.length > 1 || claims.azp !== undefined) && claims.azp !== this.config.clientId) {
|
|
171
|
+
throw new OidcError('id_token azp mismatch', 'verification');
|
|
172
|
+
}
|
|
173
|
+
if (typeof claims.nonce !== 'string' || !safeEqual(claims.nonce, nonce)) {
|
|
174
|
+
throw new OidcError('id_token nonce mismatch', 'verification');
|
|
175
|
+
}
|
|
176
|
+
return claims;
|
|
177
|
+
}
|
|
178
|
+
async exchange(endpoints, code, transaction, redirectUri) {
|
|
179
|
+
const method = this.config.tokenEndpointAuthMethod ?? (this.config.clientSecret ? 'client_secret_basic' : 'none');
|
|
180
|
+
const body = new URLSearchParams({
|
|
181
|
+
grant_type: 'authorization_code',
|
|
182
|
+
code,
|
|
183
|
+
redirect_uri: redirectUri,
|
|
184
|
+
code_verifier: transaction.codeVerifier,
|
|
185
|
+
});
|
|
186
|
+
const headers = {
|
|
187
|
+
accept: 'application/json',
|
|
188
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
189
|
+
};
|
|
190
|
+
if (method === 'client_secret_basic') {
|
|
191
|
+
// RFC 6749 §2.3.1: form-encode each part before base64.
|
|
192
|
+
const id = encodeURIComponent(this.config.clientId);
|
|
193
|
+
const secret = encodeURIComponent(this.config.clientSecret ?? '');
|
|
194
|
+
headers.authorization = `Basic ${Buffer.from(`${id}:${secret}`).toString('base64')}`;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
body.set('client_id', this.config.clientId);
|
|
198
|
+
if (method === 'client_secret_post') {
|
|
199
|
+
body.set('client_secret', this.config.clientSecret ?? '');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const response = await this.request(endpoints.token_endpoint, { method: 'POST', headers, body });
|
|
203
|
+
const json = (await response.json().catch(() => undefined));
|
|
204
|
+
const error = typeof json?.error === 'string' ? json.error : undefined;
|
|
205
|
+
if (response.status >= 500 && !error) {
|
|
206
|
+
throw new OidcError(`token endpoint unavailable (${response.status})`, 'unavailable');
|
|
207
|
+
}
|
|
208
|
+
if (!response.ok || !json || error) {
|
|
209
|
+
throw new OidcError(`token endpoint rejected the code (${error ?? response.status})`, 'verification');
|
|
210
|
+
}
|
|
211
|
+
if (typeof json.access_token !== 'string' || String(json.token_type).toLowerCase() !== 'bearer') {
|
|
212
|
+
throw new OidcError('malformed token response', 'verification');
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
accessToken: json.access_token,
|
|
216
|
+
...(typeof json.refresh_token === 'string' && { refreshToken: json.refresh_token }),
|
|
217
|
+
...(typeof json.id_token === 'string' && { idToken: json.id_token }),
|
|
218
|
+
...(typeof json.expires_in === 'number' && { expiresIn: json.expires_in }),
|
|
219
|
+
...(typeof json.scope === 'string' && { scope: json.scope }),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
async endpoints() {
|
|
223
|
+
if (this.kind === 'oauth2') {
|
|
224
|
+
return {
|
|
225
|
+
authorization_endpoint: assertSecureUrl(this.config.authorizationEndpoint, 'authorization_endpoint'),
|
|
226
|
+
token_endpoint: assertSecureUrl(this.config.tokenEndpoint, 'token_endpoint'),
|
|
227
|
+
...(this.config.userinfoEndpoint && {
|
|
228
|
+
userinfo_endpoint: assertSecureUrl(this.config.userinfoEndpoint, 'userinfo_endpoint'),
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const ttl = durationOr(this.options.discoveryTtl, '1h');
|
|
233
|
+
if (!this.discovery || this.now() - this.discovery.at >= ttl) {
|
|
234
|
+
const value = this.discover();
|
|
235
|
+
this.discovery = { value, at: this.now() };
|
|
236
|
+
value.catch(() => (this.discovery = undefined)); // retry next time
|
|
237
|
+
}
|
|
238
|
+
return this.discovery.value;
|
|
239
|
+
}
|
|
240
|
+
async discover() {
|
|
241
|
+
const issuer = this.config.issuer;
|
|
242
|
+
const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
243
|
+
const response = await this.request(assertSecureUrl(url, 'issuer'), { headers: { accept: 'application/json' } });
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
throw new OidcError(`discovery failed (${response.status})`, 'unavailable');
|
|
246
|
+
}
|
|
247
|
+
const doc = (await response.json().catch(() => ({})));
|
|
248
|
+
// OIDC Discovery §4.3: the document must name exactly the issuer we asked.
|
|
249
|
+
if (doc.issuer !== issuer) {
|
|
250
|
+
throw new OidcError('discovery issuer mismatch', 'unavailable');
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...doc,
|
|
254
|
+
authorization_endpoint: assertSecureUrl(this.config.authorizationEndpoint ?? doc.authorization_endpoint, 'authorization_endpoint'),
|
|
255
|
+
token_endpoint: assertSecureUrl(this.config.tokenEndpoint ?? doc.token_endpoint, 'token_endpoint'),
|
|
256
|
+
userinfo_endpoint: (this.config.userinfoEndpoint ?? doc.userinfo_endpoint) && assertSecureUrl(this.config.userinfoEndpoint ?? doc.userinfo_endpoint, 'userinfo_endpoint'),
|
|
257
|
+
jwks_uri: this.config.jwksUri ?? doc.jwks_uri,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async getJson(url, accessToken, what) {
|
|
261
|
+
const target = new URL(assertSecureUrl(url, 'API url'));
|
|
262
|
+
// Errors reach responses and logs: name the host and path, never a query that may carry secrets.
|
|
263
|
+
const name = what ?? `${target.host}${target.pathname}`;
|
|
264
|
+
const response = await this.request(target.toString(), {
|
|
265
|
+
headers: { accept: 'application/json', authorization: `Bearer ${accessToken}` },
|
|
266
|
+
});
|
|
267
|
+
if (response.status >= 500) {
|
|
268
|
+
throw new OidcError(`${name} unavailable (${response.status})`, 'unavailable');
|
|
269
|
+
}
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
throw new OidcError(`${name} answered ${response.status}`, 'verification');
|
|
272
|
+
}
|
|
273
|
+
return response.json();
|
|
274
|
+
}
|
|
275
|
+
async request(url, init) {
|
|
276
|
+
try {
|
|
277
|
+
return await (this.options.fetch ?? fetch)(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10_000) });
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
throw new OidcError(`${new URL(url).host} is unreachable (${error.message})`, 'unavailable');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
now() {
|
|
284
|
+
return this.options.now?.() ?? Date.now();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OidcProviderConfig } from '../interfaces/oidc-provider.interface.js';
|
|
2
|
+
type Credentials = {
|
|
3
|
+
clientId: string;
|
|
4
|
+
clientSecret: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function google(credentials: Credentials): OidcProviderConfig;
|
|
7
|
+
/**
|
|
8
|
+
* Microsoft Entra ID, single tenant, by its tenant id (a GUID). Whatever
|
|
9
|
+
* name discovery is asked with, a domain included, Entra publishes the
|
|
10
|
+
* issuer of the tenant id, which must match exactly. Multi-tenant endpoints
|
|
11
|
+
* (`common`, `organizations`, `consumers`) publish a templated issuer
|
|
12
|
+
* (`https://login.microsoftonline.com/{tenantid}/v2.0`) that needs per-token
|
|
13
|
+
* issuer validation against `tid`; not supported.
|
|
14
|
+
*/
|
|
15
|
+
export declare function microsoft({ tenant, ...credentials }: Credentials & {
|
|
16
|
+
tenant: string;
|
|
17
|
+
}): OidcProviderConfig;
|
|
18
|
+
/**
|
|
19
|
+
* GitHub is OAuth 2.0, not OIDC: no discovery, no ID token. The profile
|
|
20
|
+
* comes from `GET /user`, and the email from `GET /user/emails` (primary and
|
|
21
|
+
* verified only), since the public profile email is user-controlled.
|
|
22
|
+
*/
|
|
23
|
+
export declare function github(credentials: Credentials): OidcProviderConfig;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function google(credentials) {
|
|
2
|
+
return { issuer: 'https://accounts.google.com', ...credentials };
|
|
3
|
+
}
|
|
4
|
+
const TENANT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
/**
|
|
6
|
+
* Microsoft Entra ID, single tenant, by its tenant id (a GUID). Whatever
|
|
7
|
+
* name discovery is asked with, a domain included, Entra publishes the
|
|
8
|
+
* issuer of the tenant id, which must match exactly. Multi-tenant endpoints
|
|
9
|
+
* (`common`, `organizations`, `consumers`) publish a templated issuer
|
|
10
|
+
* (`https://login.microsoftonline.com/{tenantid}/v2.0`) that needs per-token
|
|
11
|
+
* issuer validation against `tid`; not supported.
|
|
12
|
+
*/
|
|
13
|
+
export function microsoft({ tenant, ...credentials }) {
|
|
14
|
+
if (typeof tenant !== 'string' || !TENANT_ID.test(tenant)) {
|
|
15
|
+
throw new Error(`microsoft(): pass the tenant id, a GUID (Entra admin center, Overview), not ${JSON.stringify(tenant)}: ` +
|
|
16
|
+
'for a domain, and for the multi-tenant common, organizations and consumers, Microsoft publishes an ' +
|
|
17
|
+
'issuer that is not the one discovery was asked for, and every sign-in would fail.');
|
|
18
|
+
}
|
|
19
|
+
return { issuer: `https://login.microsoftonline.com/${tenant.toLowerCase()}/v2.0`, ...credentials };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* GitHub is OAuth 2.0, not OIDC: no discovery, no ID token. The profile
|
|
23
|
+
* comes from `GET /user`, and the email from `GET /user/emails` (primary and
|
|
24
|
+
* verified only), since the public profile email is user-controlled.
|
|
25
|
+
*/
|
|
26
|
+
export function github(credentials) {
|
|
27
|
+
return {
|
|
28
|
+
kind: 'oauth2',
|
|
29
|
+
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
|
|
30
|
+
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
|
31
|
+
userinfoEndpoint: 'https://api.github.com/user',
|
|
32
|
+
scopes: ['read:user', 'user:email'],
|
|
33
|
+
...credentials,
|
|
34
|
+
async profile(user, { provider, fetchJson }) {
|
|
35
|
+
if (typeof user?.id !== 'number' && typeof user?.id !== 'string') {
|
|
36
|
+
throw new Error('unexpected GitHub user');
|
|
37
|
+
}
|
|
38
|
+
const emails = await fetchJson(`${this.userinfoEndpoint}/emails`);
|
|
39
|
+
const primary = Array.isArray(emails)
|
|
40
|
+
? emails.find((e) => e?.primary === true && e.verified === true && typeof e.email === 'string')
|
|
41
|
+
: undefined;
|
|
42
|
+
return {
|
|
43
|
+
provider,
|
|
44
|
+
subject: String(user.id),
|
|
45
|
+
email: primary?.email,
|
|
46
|
+
emailVerified: !!primary,
|
|
47
|
+
name: user.name ?? user.login,
|
|
48
|
+
picture: user.avatar_url,
|
|
49
|
+
claims: user,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { HttpAdapterHost } from '@nestjs/core';
|
|
2
|
+
import { AuthenticationScope } from '../context/authentication-scope.service.js';
|
|
3
|
+
import { AuthenticationRegistry } from '../services/authentication-registry.service.js';
|
|
4
|
+
import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
5
|
+
import { SessionService } from '../session/session.service.js';
|
|
6
|
+
import { SignInService } from '../session/sign-in.service.js';
|
|
7
|
+
import type { OidcOptions, OidcRequest, OidcRedirect, OidcStartOptions } from '../interfaces/oidc.interface.js';
|
|
8
|
+
import { OidcClient } from './oidc.client.js';
|
|
9
|
+
/**
|
|
10
|
+
* OpenID Connect sign-in, behind two routes of your own: a login route that
|
|
11
|
+
* redirects to the provider, and the callback route it sends the browser
|
|
12
|
+
* back to. Called from an HTTP handler, each method reads the request and
|
|
13
|
+
* sets its cookies on the response, on Express and Fastify alike:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* @Public()
|
|
17
|
+
* @Controller('auth/oidc')
|
|
18
|
+
* export class OidcController {
|
|
19
|
+
* constructor(private readonly oidc: OidcService) {}
|
|
20
|
+
*
|
|
21
|
+
* @Get(':provider/login')
|
|
22
|
+
* @Redirect()
|
|
23
|
+
* login(@Param('provider') provider: string, @Query('redirectTo') redirectTo?: string, @Query('link') link?: string) {
|
|
24
|
+
* return this.oidc.start(provider, { redirectTo, link: link === 'true' });
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* @Get(':provider/callback')
|
|
28
|
+
* @Redirect()
|
|
29
|
+
* callback(@Param('provider') provider: string, @Query() query: Record<string, string>) {
|
|
30
|
+
* return this.oidc.finish(provider, query);
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* The transaction (PKCE verifier, nonce, return path) stays server-side,
|
|
36
|
+
* keyed by `state`; the browser gets, in an `HttpOnly` cookie, the secret
|
|
37
|
+
* whose SHA-256 is the `state`. The callback must present both, which binds
|
|
38
|
+
* the response to the browser that started the login: another browser
|
|
39
|
+
* cannot finish it (login CSRF), nor can whoever reads the callback URL in a
|
|
40
|
+
* log or a history. `SameSite=Lax` lets the cookie through the IdP's
|
|
41
|
+
* top-level redirect, and the `__Host-` prefix keeps sibling subdomains from
|
|
42
|
+
* planting one.
|
|
43
|
+
*
|
|
44
|
+
* `start(provider, { link: true })` begins a link flow for the signed-in
|
|
45
|
+
* user instead of a sign-in: the callback re-checks that the same session is
|
|
46
|
+
* still live, hands `{ linkTo }` to the resolver, and keeps the session as
|
|
47
|
+
* it is.
|
|
48
|
+
*/
|
|
49
|
+
export declare class OidcService {
|
|
50
|
+
private readonly storage;
|
|
51
|
+
private readonly registry;
|
|
52
|
+
private readonly signIn;
|
|
53
|
+
private readonly sessions;
|
|
54
|
+
private readonly scope;
|
|
55
|
+
private readonly adapterHost?;
|
|
56
|
+
private readonly clients;
|
|
57
|
+
private readonly options?;
|
|
58
|
+
private readonly transactionTtl;
|
|
59
|
+
private readonly txCookieName;
|
|
60
|
+
constructor(storage: AuthenticationStorage, registry: AuthenticationRegistry, signIn: SignInService, sessions: SessionService, options?: {
|
|
61
|
+
oidc?: OidcOptions;
|
|
62
|
+
}, scope?: AuthenticationScope, adapterHost?: HttpAdapterHost | undefined);
|
|
63
|
+
client(name: string): OidcClient;
|
|
64
|
+
/**
|
|
65
|
+
* Starts a login: stores the transaction, sets its cookie, and returns
|
|
66
|
+
* the provider's authorization URL. 404 for a provider that isn't
|
|
67
|
+
* configured; 401 for a link flow without a signed-in session.
|
|
68
|
+
*/
|
|
69
|
+
start(provider: string, { redirectTo, link, request }?: OidcStartOptions): Promise<OidcRedirect>;
|
|
70
|
+
/**
|
|
71
|
+
* Completes a login on the callback route, and returns where to send the
|
|
72
|
+
* browser: the login's `redirectTo`, else `redirectAfterLogin`. A sign-in
|
|
73
|
+
* goes through `SignInService`, which sets the session cookie on the
|
|
74
|
+
* response; the transaction cookie is cleared.
|
|
75
|
+
*/
|
|
76
|
+
finish(provider: string, query: Record<string, unknown>, { request }?: {
|
|
77
|
+
request?: OidcRequest;
|
|
78
|
+
}): Promise<OidcRedirect>;
|
|
79
|
+
/** Sets the cookies (and `no-store`) on the HTTP response, if there is one. */
|
|
80
|
+
private redirect;
|
|
81
|
+
/** The options and the resolver; the module refuses to start with one and not the other. */
|
|
82
|
+
private feature;
|
|
83
|
+
/** A fully signed-in session (second factor done, if any) on this request. */
|
|
84
|
+
private currentSession;
|
|
85
|
+
private callbackUrl;
|
|
86
|
+
private txCookie;
|
|
87
|
+
private now;
|
|
88
|
+
}
|