@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 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>, returnPathname?: string | null, context?: OidcCallContext): Promise<{
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, returnPathname, context) {
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
- return { ok: false, error, setCookies: clearVerifier };
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, returnPathname, context) {
184
- return begin("session", request, cookies, returnPathname, context);
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
- return { ok: false, error, setCookies: clearVerifier };
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),
@@ -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
- /** Whether the provider returned access_denied in its authorization response. */
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
- /** Whether the provider returned access_denied in its authorization response. */
62
- export function isAuthorizationDenied(error) {
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 === "access_denied";
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
@@ -22,6 +22,7 @@ declare const sessionSchema: z.ZodObject<{
22
22
  expiresAt: z.ZodNumber;
23
23
  sid: z.ZodString;
24
24
  issuedAt: z.ZodNumber;
25
+ authTime: z.ZodOptional<z.ZodNumber>;
25
26
  lastSeenAt: z.ZodNumber;
26
27
  idpSid: z.ZodOptional<z.ZodString>;
27
28
  }, z.core.$strip>;
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lpshanley/oidc-start-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",