@lpshanley/oidc-start-core 0.2.0 → 0.3.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/dist/core.d.ts +6 -2
- package/dist/core.js +32 -15
- package/dist/index.d.ts +1 -1
- package/dist/models.d.ts +13 -0
- package/dist/models.js +8 -0
- package/dist/provider.d.ts +8 -4
- package/dist/provider.js +18 -4
- package/dist/session.d.ts +1 -0
- package/dist/session.js +2 -0
- package/dist/state.d.ts +1 -0
- package/dist/state.js +1 -0
- package/package.json +1 -1
package/dist/core.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type OidcCallContext, type OidcErrorEvent, type OidcMiddlewareOptions } from "./config.js";
|
|
2
|
-
import { type AuthUser } from "./models.js";
|
|
2
|
+
import { type AuthUser, type SignInOptions } from "./models.js";
|
|
3
3
|
import { type TokenSet } from "./provider.js";
|
|
4
4
|
import { type Session } from "./session.js";
|
|
5
5
|
export type OidcAuth = {
|
|
@@ -20,12 +20,14 @@ export declare class SessionAccessDeniedError extends Error {
|
|
|
20
20
|
name: string;
|
|
21
21
|
constructor();
|
|
22
22
|
}
|
|
23
|
+
export type CallbackErrorReason = "invalid_callback" | "sign_in_expired" | "invalid_state" | "access_denied" | "provider_error" | "token_exchange_failed" | "session_failed";
|
|
23
24
|
export type CallbackResult = {
|
|
24
25
|
ok: true;
|
|
25
26
|
location: string;
|
|
26
27
|
setCookies: string[];
|
|
27
28
|
} | {
|
|
28
29
|
ok: false;
|
|
30
|
+
reason: CallbackErrorReason | "session_access_denied";
|
|
29
31
|
error: unknown;
|
|
30
32
|
setCookies: string[];
|
|
31
33
|
};
|
|
@@ -37,6 +39,7 @@ export type GrantResult = {
|
|
|
37
39
|
setCookies: string[];
|
|
38
40
|
} | {
|
|
39
41
|
ok: false;
|
|
42
|
+
reason: CallbackErrorReason;
|
|
40
43
|
error: unknown;
|
|
41
44
|
setCookies: string[];
|
|
42
45
|
};
|
|
@@ -66,12 +69,13 @@ export declare function createOidcCore(options: OidcMiddlewareOptions): {
|
|
|
66
69
|
refreshToken?: string | undefined;
|
|
67
70
|
refreshGeneration?: number | undefined;
|
|
68
71
|
idToken?: string | undefined;
|
|
72
|
+
authTime?: number | undefined;
|
|
69
73
|
idpSid?: string | undefined;
|
|
70
74
|
} | null;
|
|
71
75
|
setCookies: string[];
|
|
72
76
|
}>;
|
|
73
77
|
/** An authorization URL plus the flow's verifier cookie. */
|
|
74
|
-
signIn(request: Request, cookies: Map<string, string>,
|
|
78
|
+
signIn(request: Request, cookies: Map<string, string>, options?: string | null | SignInOptions, context?: OidcCallContext): Promise<{
|
|
75
79
|
url: string;
|
|
76
80
|
setCookies: string[];
|
|
77
81
|
}>;
|
package/dist/core.js
CHANGED
|
@@ -3,8 +3,8 @@ import { randomNonce, randomPKCECodeVerifier } from "openid-client";
|
|
|
3
3
|
import { assertOidcConfigured, authOriginConfigurationSchema, authServiceOptionsSchema, oidcConfigurationSchema, sessionConfigurationSchema, } from "./config.js";
|
|
4
4
|
import { parseCookies, serializeCookie, } from "./cookies.js";
|
|
5
5
|
import { LOGOUT_TOKEN_MAX_AGE_SECONDS, LogoutTokenError } from "./logout-token.js";
|
|
6
|
-
import { sanitizeReturnPath } from "./models.js";
|
|
7
|
-
import { createProvider, } from "./provider.js";
|
|
6
|
+
import { sanitizeReturnPath, signInOptionsSchema } from "./models.js";
|
|
7
|
+
import { authorizationErrorCode, createProvider, } from "./provider.js";
|
|
8
8
|
import { createSessionManager } from "./session.js";
|
|
9
9
|
import { OidcStateError, STATE_MAX_AGE_SECONDS, sealState, staleVerifierNames, verifierCookieName, verifyCallbackState, } from "./state.js";
|
|
10
10
|
/** The caller's deadline passed before the provider answered. */
|
|
@@ -99,18 +99,22 @@ export function createOidcCore(options) {
|
|
|
99
99
|
? redirectUri(request)
|
|
100
100
|
: `${origin(request)}${settings.grantCallbackPath}`;
|
|
101
101
|
}
|
|
102
|
-
async function begin(purpose, request, cookies,
|
|
102
|
+
async function begin(purpose, request, cookies, options, context) {
|
|
103
103
|
const nonce = randomNonce();
|
|
104
104
|
const codeVerifier = randomPKCECodeVerifier();
|
|
105
105
|
const state = await sealState({
|
|
106
106
|
purpose,
|
|
107
107
|
nonce,
|
|
108
108
|
codeVerifier,
|
|
109
|
-
returnPathname: sanitizeReturnPath(returnPathname),
|
|
109
|
+
returnPathname: sanitizeReturnPath(options.returnPathname),
|
|
110
|
+
maxAge: options.maxAge,
|
|
110
111
|
}, password());
|
|
111
112
|
const name = verifierCookieName(prefix(), state);
|
|
112
113
|
const url = await withinDeadline(provider().authorizationUrl({
|
|
113
114
|
redirectUri: callbackUri(purpose, request),
|
|
115
|
+
loginHint: options.loginHint,
|
|
116
|
+
maxAge: options.maxAge,
|
|
117
|
+
prompt: options.prompt,
|
|
114
118
|
state,
|
|
115
119
|
nonce,
|
|
116
120
|
codeVerifier,
|
|
@@ -136,29 +140,37 @@ export function createOidcCore(options) {
|
|
|
136
140
|
const clearVerifier = state
|
|
137
141
|
? [expire(verifierCookieName(prefix(), state))]
|
|
138
142
|
: [];
|
|
143
|
+
if (!state || !(url.searchParams.get("code") || url.searchParams.get("error"))) {
|
|
144
|
+
return { ok: false, reason: "invalid_callback", error: new OidcStateError("missing"), setCookies: clearVerifier };
|
|
145
|
+
}
|
|
146
|
+
let reason = "invalid_state";
|
|
139
147
|
try {
|
|
140
|
-
if (!state || !(url.searchParams.get("code") || url.searchParams.get("error"))) {
|
|
141
|
-
throw new OidcStateError("missing");
|
|
142
|
-
}
|
|
143
148
|
const flow = await verifyCallbackState({
|
|
144
149
|
state,
|
|
145
150
|
cookieValue: cookies.get(verifierCookieName(prefix(), state)),
|
|
146
151
|
password: password(),
|
|
147
152
|
purpose,
|
|
148
153
|
});
|
|
154
|
+
reason = "token_exchange_failed";
|
|
149
155
|
// Rebuild the callback on the registered redirect URI so a proxy
|
|
150
156
|
// in front of the app doesn't change the URL being verified.
|
|
151
157
|
const callbackUrl = new URL(callbackUri(purpose, request));
|
|
152
158
|
callbackUrl.search = url.search;
|
|
153
|
-
const { user, tokens, idpSid } = await withinDeadline(provider().exchangeCode(callbackUrl, {
|
|
159
|
+
const { user, tokens, idpSid, authTime } = await withinDeadline(provider().exchangeCode(callbackUrl, {
|
|
154
160
|
state,
|
|
155
161
|
nonce: flow.nonce,
|
|
156
162
|
codeVerifier: flow.codeVerifier,
|
|
163
|
+
maxAge: flow.maxAge,
|
|
157
164
|
}, context), context);
|
|
158
|
-
return { ok: true, flow, user, tokens, idpSid, clearVerifier };
|
|
165
|
+
return { ok: true, flow, user, tokens, idpSid, authTime, clearVerifier };
|
|
159
166
|
}
|
|
160
167
|
catch (error) {
|
|
161
|
-
|
|
168
|
+
const code = authorizationErrorCode(error);
|
|
169
|
+
if (code)
|
|
170
|
+
reason = code === "access_denied" ? "access_denied" : "provider_error";
|
|
171
|
+
else if (error instanceof OidcStateError && error.reason === "missing")
|
|
172
|
+
reason = "sign_in_expired";
|
|
173
|
+
return { ok: false, reason, error, setCookies: clearVerifier };
|
|
162
174
|
}
|
|
163
175
|
}
|
|
164
176
|
const sessions = createSessionManager({
|
|
@@ -180,15 +192,18 @@ export function createOidcCore(options) {
|
|
|
180
192
|
return { cookies, session, setCookies };
|
|
181
193
|
},
|
|
182
194
|
/** An authorization URL plus the flow's verifier cookie. */
|
|
183
|
-
signIn(request, cookies,
|
|
184
|
-
|
|
195
|
+
signIn(request, cookies, options, context) {
|
|
196
|
+
const signInOptions = typeof options === "string"
|
|
197
|
+
? { returnPathname: options }
|
|
198
|
+
: signInOptionsSchema.parse(options ?? {});
|
|
199
|
+
return begin("session", request, cookies, signInOptions, context);
|
|
185
200
|
},
|
|
186
201
|
/** Verifies the flow, exchanges the code, and issues the session. */
|
|
187
202
|
async callback(request, cookies, context) {
|
|
188
203
|
const result = await complete("session", request, cookies, context);
|
|
189
204
|
if (!result.ok)
|
|
190
205
|
return result;
|
|
191
|
-
const { user, tokens, idpSid, flow, clearVerifier } = result;
|
|
206
|
+
const { user, tokens, idpSid, authTime, flow, clearVerifier } = result;
|
|
192
207
|
try {
|
|
193
208
|
if (!options.allowSession(user))
|
|
194
209
|
throw new SessionAccessDeniedError();
|
|
@@ -202,6 +217,7 @@ export function createOidcCore(options) {
|
|
|
202
217
|
...tokens,
|
|
203
218
|
sid: crypto.randomUUID(),
|
|
204
219
|
...(idpSid ? { idpSid } : {}),
|
|
220
|
+
authTime,
|
|
205
221
|
issuedAt: now,
|
|
206
222
|
lastSeenAt: now,
|
|
207
223
|
})),
|
|
@@ -210,7 +226,8 @@ export function createOidcCore(options) {
|
|
|
210
226
|
};
|
|
211
227
|
}
|
|
212
228
|
catch (error) {
|
|
213
|
-
|
|
229
|
+
const reason = error instanceof SessionAccessDeniedError ? "session_access_denied" : "session_failed";
|
|
230
|
+
return { ok: false, reason, error, setCookies: clearVerifier };
|
|
214
231
|
}
|
|
215
232
|
},
|
|
216
233
|
/**
|
|
@@ -219,7 +236,7 @@ export function createOidcCore(options) {
|
|
|
219
236
|
* session, so the browser's own session is untouched.
|
|
220
237
|
*/
|
|
221
238
|
startGrant(request, cookies, returnPathname, context) {
|
|
222
|
-
return begin("grant", request, cookies, returnPathname, context);
|
|
239
|
+
return begin("grant", request, cookies, { returnPathname }, context);
|
|
223
240
|
},
|
|
224
241
|
/** Verifies a grant's callback and exchanges its code for tokens. */
|
|
225
242
|
async finishGrant(request, cookies, context) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createOidcCore, SessionAccessDeniedError, type OidcCore, type OidcAuth, type GrantRefresh, type GrantResult, } from "./core.js";
|
|
1
|
+
export { createOidcCore, SessionAccessDeniedError, type OidcCore, type CallbackErrorReason, type OidcAuth, type GrantRefresh, type GrantResult, } from "./core.js";
|
|
2
2
|
export type { SessionRefreshCoordinator } from "./refresh.js";
|
|
3
3
|
export type { Session, SessionRevocations } from "./session.js";
|
|
4
4
|
export type { OidcMiddlewareOptions, OidcCallContext } from "./config.js";
|
package/dist/models.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/** Options for a single authorization request. Omit prompt to allow provider SSO. */
|
|
3
|
+
export declare const signInOptionsSchema: z.ZodObject<{
|
|
4
|
+
returnPathname: z.ZodOptional<z.ZodString>;
|
|
5
|
+
maxAge: z.ZodOptional<z.ZodNumber>;
|
|
6
|
+
loginHint: z.ZodOptional<z.ZodString>;
|
|
7
|
+
prompt: z.ZodOptional<z.ZodEnum<{
|
|
8
|
+
none: "none";
|
|
9
|
+
login: "login";
|
|
10
|
+
consent: "consent";
|
|
11
|
+
select_account: "select_account";
|
|
12
|
+
}>>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
export type SignInOptions = z.infer<typeof signInOptionsSchema>;
|
|
2
15
|
export declare const authUserSchema: z.ZodObject<{
|
|
3
16
|
sub: z.ZodString;
|
|
4
17
|
email: z.ZodString;
|
package/dist/models.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/** Options for a single authorization request. Omit prompt to allow provider SSO. */
|
|
3
|
+
export const signInOptionsSchema = z.object({
|
|
4
|
+
returnPathname: z.string().optional(),
|
|
5
|
+
/** Maximum authentication age in seconds. Zero requests a fresh login. */
|
|
6
|
+
maxAge: z.number().int().nonnegative().optional(),
|
|
7
|
+
loginHint: z.string().min(1).optional(),
|
|
8
|
+
prompt: z.enum(["none", "login", "consent", "select_account"]).optional(),
|
|
9
|
+
});
|
|
2
10
|
export const authUserSchema = z.object({
|
|
3
11
|
sub: z.string().min(1),
|
|
4
12
|
email: z.string().min(1),
|
package/dist/provider.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OidcCallContext, OidcConfiguration } from "./config.js";
|
|
2
|
-
import type { AuthUser } from "./models.js";
|
|
2
|
+
import type { AuthUser, SignInOptions } from "./models.js";
|
|
3
3
|
export type TokenSet = {
|
|
4
4
|
accessToken: string;
|
|
5
5
|
refreshToken?: string;
|
|
@@ -17,27 +17,31 @@ export declare const PROVIDER_TIMEOUT_MS = 5000;
|
|
|
17
17
|
/** The provider has not received a token refresh request. */
|
|
18
18
|
export declare class RefreshNotDispatched extends Error {
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/** OAuth error code from an authorization response, including across bundles. */
|
|
21
|
+
export declare function authorizationErrorCode(error: unknown): string | undefined;
|
|
21
22
|
export declare function isAuthorizationDenied(error: unknown): boolean;
|
|
22
23
|
export declare function createProvider(settings: OidcConfiguration, timeoutMs?: number): {
|
|
23
|
-
authorizationUrl: (input: Parameters<({ redirectUri, state, nonce, codeVerifier, }: {
|
|
24
|
+
authorizationUrl: (input: Parameters<({ maxAge, loginHint, prompt, redirectUri, state, nonce, codeVerifier, }: {
|
|
24
25
|
redirectUri: string;
|
|
25
26
|
state: string;
|
|
26
27
|
nonce: string;
|
|
27
28
|
codeVerifier: string;
|
|
28
|
-
}) => Promise<URL>>[0], context?: OidcCallContext) => Promise<URL>;
|
|
29
|
+
} & Pick<SignInOptions, "loginHint" | "prompt" | "maxAge">) => Promise<URL>>[0], context?: OidcCallContext) => Promise<URL>;
|
|
29
30
|
exchangeCode: (url: URL, checks: Parameters<(callbackUrl: URL, checks: {
|
|
30
31
|
state: string;
|
|
31
32
|
nonce: string;
|
|
32
33
|
codeVerifier: string;
|
|
34
|
+
maxAge?: number;
|
|
33
35
|
}) => Promise<{
|
|
34
36
|
user: AuthUser;
|
|
35
37
|
tokens: TokenSet;
|
|
36
38
|
idpSid?: string;
|
|
39
|
+
authTime?: number;
|
|
37
40
|
}>>[1], context?: OidcCallContext) => Promise<{
|
|
38
41
|
user: AuthUser;
|
|
39
42
|
tokens: TokenSet;
|
|
40
43
|
idpSid?: string;
|
|
44
|
+
authTime?: number;
|
|
41
45
|
}>;
|
|
42
46
|
refreshGrantTokens: (token: string, context?: OidcCallContext) => Promise<TokenSet>;
|
|
43
47
|
refresh: (token: string, subject: string, context?: OidcCallContext) => Promise<RefreshedTokens>;
|
package/dist/provider.js
CHANGED
|
@@ -58,11 +58,14 @@ export const PROVIDER_TIMEOUT_MS = 5_000;
|
|
|
58
58
|
/** The provider has not received a token refresh request. */
|
|
59
59
|
export class RefreshNotDispatched extends Error {
|
|
60
60
|
}
|
|
61
|
-
/**
|
|
62
|
-
export function
|
|
61
|
+
/** OAuth error code from an authorization response, including across bundles. */
|
|
62
|
+
export function authorizationErrorCode(error) {
|
|
63
63
|
return typeof error === "object" && error !== null
|
|
64
64
|
&& "code" in error && error.code === "OAUTH_AUTHORIZATION_RESPONSE_ERROR"
|
|
65
|
-
&& "error" in error && error.error === "
|
|
65
|
+
&& "error" in error && typeof error.error === "string" ? error.error : undefined;
|
|
66
|
+
}
|
|
67
|
+
export function isAuthorizationDenied(error) {
|
|
68
|
+
return authorizationErrorCode(error) === "access_denied";
|
|
66
69
|
}
|
|
67
70
|
export function createProvider(settings, timeoutMs = PROVIDER_TIMEOUT_MS) {
|
|
68
71
|
// The SDK caches discovery and JWKS clients and accepts a single transport hook.
|
|
@@ -136,10 +139,13 @@ export function createProvider(settings, timeoutMs = PROVIDER_TIMEOUT_MS) {
|
|
|
136
139
|
return { config, response };
|
|
137
140
|
}
|
|
138
141
|
const api = {
|
|
139
|
-
async authorizationUrl({ redirectUri, state, nonce, codeVerifier, }) {
|
|
142
|
+
async authorizationUrl({ maxAge, loginHint, prompt, redirectUri, state, nonce, codeVerifier, }) {
|
|
140
143
|
return client.buildAuthorizationUrl(await discover(), {
|
|
141
144
|
redirect_uri: redirectUri,
|
|
142
145
|
scope: settings.OIDC_SCOPES,
|
|
146
|
+
...(loginHint && { login_hint: loginHint }),
|
|
147
|
+
...(prompt && { prompt }),
|
|
148
|
+
...(maxAge !== undefined && { max_age: String(maxAge) }),
|
|
143
149
|
state,
|
|
144
150
|
nonce,
|
|
145
151
|
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
|
@@ -152,11 +158,18 @@ export function createProvider(settings, timeoutMs = PROVIDER_TIMEOUT_MS) {
|
|
|
152
158
|
pkceCodeVerifier: checks.codeVerifier,
|
|
153
159
|
expectedState: checks.state,
|
|
154
160
|
expectedNonce: checks.nonce,
|
|
161
|
+
maxAge: checks.maxAge,
|
|
155
162
|
idTokenExpected: true,
|
|
156
163
|
});
|
|
157
164
|
const claims = response.claims();
|
|
158
165
|
if (!claims?.sub)
|
|
159
166
|
throw new Error("ID token has no subject");
|
|
167
|
+
// openid-client validates the requested age. A future authentication time
|
|
168
|
+
// must never become evidence of a recent login as the local clock advances.
|
|
169
|
+
const authTime = claims.auth_time;
|
|
170
|
+
const inFuture = authTime !== undefined && authTime > Math.floor(Date.now() / 1000);
|
|
171
|
+
if (inFuture && checks.maxAge !== undefined)
|
|
172
|
+
throw new Error("ID token authentication time is in the future");
|
|
160
173
|
let email = firstString(claims.email);
|
|
161
174
|
const tokenRoles = roleList(claims[settings.OIDC_ROLES_CLAIM]);
|
|
162
175
|
let info;
|
|
@@ -176,6 +189,7 @@ export function createProvider(settings, timeoutMs = PROVIDER_TIMEOUT_MS) {
|
|
|
176
189
|
roles: tokenRoles ?? roleList(info?.[settings.OIDC_ROLES_CLAIM]) ?? [],
|
|
177
190
|
},
|
|
178
191
|
tokens: tokenSet(response),
|
|
192
|
+
authTime: inFuture ? undefined : authTime,
|
|
179
193
|
// The provider's session id, matched by back-channel logout tokens.
|
|
180
194
|
idpSid: firstString(claims.sid) ?? undefined,
|
|
181
195
|
};
|
package/dist/session.d.ts
CHANGED
package/dist/session.js
CHANGED
|
@@ -21,6 +21,8 @@ const sessionSchema = z.object({
|
|
|
21
21
|
sid: z.string().min(1),
|
|
22
22
|
/** Seconds since epoch of sign-in; bounds the absolute lifetime. */
|
|
23
23
|
issuedAt: z.number(),
|
|
24
|
+
/** Provider authentication time from the sign-in ID token, in epoch seconds. */
|
|
25
|
+
authTime: z.number().nonnegative().optional(),
|
|
24
26
|
/** Seconds since epoch of the last request; bounds the idle lifetime. */
|
|
25
27
|
lastSeenAt: z.number(),
|
|
26
28
|
/** The provider's session id (ID token `sid`), when it sends one. */
|
package/dist/state.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ declare const flowStateSchema: z.ZodObject<{
|
|
|
15
15
|
nonce: z.ZodString;
|
|
16
16
|
codeVerifier: z.ZodString;
|
|
17
17
|
returnPathname: z.ZodString;
|
|
18
|
+
maxAge: z.ZodOptional<z.ZodNumber>;
|
|
18
19
|
issuedAt: z.ZodNumber;
|
|
19
20
|
}, z.core.$strip>;
|
|
20
21
|
export type FlowState = z.infer<typeof flowStateSchema>;
|
package/dist/state.js
CHANGED
|
@@ -13,6 +13,7 @@ const flowStateSchema = z.object({
|
|
|
13
13
|
nonce: z.string().min(1),
|
|
14
14
|
codeVerifier: z.string().min(1),
|
|
15
15
|
returnPathname: z.string(),
|
|
16
|
+
maxAge: z.number().int().nonnegative().optional(),
|
|
16
17
|
issuedAt: z.number(),
|
|
17
18
|
});
|
|
18
19
|
export class OidcStateError extends Error {
|