auth-vir 2.7.2 → 3.0.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.
@@ -1,21 +1,20 @@
1
1
  import { HttpStatus, } from '@augment-vir/common';
2
2
  import { listenToActivity } from 'detect-activity';
3
- import { CsrfTokenFailureReason, extractCsrfTokenHeader, getCurrentCsrfToken, storeCsrfToken, wipeCurrentCsrfToken, } from '../csrf-token.js';
3
+ import { CsrfTokenFailureReason, defaultAllowedClockSkew, extractCsrfTokenHeader, getCurrentCsrfToken, resolveCsrfHeaderName, storeCsrfToken, wipeCurrentCsrfToken, } from '../csrf-token.js';
4
4
  import { AuthHeaderName } from '../headers.js';
5
- import { authLog } from '../log.js';
6
5
  /**
7
6
  * An auth client for sending and validating client requests to a backend. This should only be used
8
7
  * in a frontend environment as it accesses native browser APIs.
9
8
  *
10
9
  * @category Auth : Client
11
- * @category Client
10
+ * @category Clients
12
11
  */
13
12
  export class FrontendAuthClient {
14
13
  config;
15
14
  userCheckInterval;
16
15
  /** Used to clean up the activity listener on `.destroy()`. */
17
16
  removeActivityListener;
18
- constructor(config = {}) {
17
+ constructor(config) {
19
18
  this.config = config;
20
19
  if (config.checkUser) {
21
20
  this.removeActivityListener = listenToActivity({
@@ -41,19 +40,22 @@ export class FrontendAuthClient {
41
40
  this.removeActivityListener?.();
42
41
  }
43
42
  /** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
44
- async getCurrentCsrfToken() {
45
- const csrfTokenResult = getCurrentCsrfToken(this.config.overrides);
46
- if (csrfTokenResult.failure &&
47
- csrfTokenResult.failure !== CsrfTokenFailureReason.DoesNotExist) {
48
- authLog('auth-vir: LOGOUT - getCurrentCsrfToken: invalid CSRF token', {
49
- failure: csrfTokenResult.failure,
50
- });
51
- await this.logout();
43
+ getCurrentCsrfToken() {
44
+ const csrfTokenResult = getCurrentCsrfToken({
45
+ ...this.config.csrf,
46
+ localStorage: this.config.overrides?.localStorage,
47
+ allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
48
+ });
49
+ if (csrfTokenResult.failure) {
50
+ if (csrfTokenResult.failure !== CsrfTokenFailureReason.DoesNotExist) {
51
+ wipeCurrentCsrfToken({
52
+ ...this.config.csrf,
53
+ localStorage: this.config.overrides?.localStorage,
54
+ });
55
+ }
52
56
  return undefined;
53
57
  }
54
- else {
55
- return csrfTokenResult.csrfToken?.token;
56
- }
58
+ return csrfTokenResult.csrfToken.token;
57
59
  }
58
60
  /**
59
61
  * Assume the given user. Pass `undefined` to wipe the currently assumed user.
@@ -62,7 +64,7 @@ export class FrontendAuthClient {
62
64
  */
63
65
  async assumeUser(assumedUserParams) {
64
66
  const localStorage = this.config.overrides?.localStorage || globalThis.localStorage;
65
- const storageKey = this.config.overrides?.assumedUserHeaderName || AuthHeaderName.AssumedUser;
67
+ const storageKey = this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser;
66
68
  if (!assumedUserParams) {
67
69
  localStorage.removeItem(storageKey);
68
70
  return true;
@@ -75,7 +77,7 @@ export class FrontendAuthClient {
75
77
  }
76
78
  /** Gets the assumed user params stored in local storage, if any. */
77
79
  getAssumedUser() {
78
- const rawValue = (this.config.overrides?.localStorage || globalThis.localStorage).getItem(this.config.overrides?.assumedUserHeaderName || AuthHeaderName.AssumedUser);
80
+ const rawValue = (this.config.overrides?.localStorage || globalThis.localStorage).getItem(this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser);
79
81
  if (!rawValue) {
80
82
  return undefined;
81
83
  }
@@ -92,18 +94,18 @@ export class FrontendAuthClient {
92
94
  * `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
93
95
  * combine them with these.
94
96
  */
95
- async createAuthenticatedRequestInit() {
96
- const csrfToken = await this.getCurrentCsrfToken();
97
+ createAuthenticatedRequestInit() {
98
+ const csrfToken = this.getCurrentCsrfToken();
97
99
  const assumedUser = this.getAssumedUser();
98
100
  const headers = {
99
101
  ...(csrfToken
100
102
  ? {
101
- [AuthHeaderName.CsrfToken]: csrfToken,
103
+ [resolveCsrfHeaderName(this.config.csrf)]: csrfToken,
102
104
  }
103
105
  : {}),
104
106
  ...(assumedUser
105
107
  ? {
106
- [this.config.overrides?.assumedUserHeaderName || AuthHeaderName.AssumedUser]: JSON.stringify(assumedUser),
108
+ [this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser]: JSON.stringify(assumedUser),
107
109
  }
108
110
  : {}),
109
111
  };
@@ -114,9 +116,11 @@ export class FrontendAuthClient {
114
116
  }
115
117
  /** Wipes the current user auth. */
116
118
  async logout() {
117
- authLog('auth-vir: LOGOUT - FrontendAuthClient.logout called', new Error().stack);
118
119
  await this.config.authClearedCallback?.();
119
- wipeCurrentCsrfToken(this.config.overrides);
120
+ wipeCurrentCsrfToken({
121
+ ...this.config.csrf,
122
+ localStorage: this.config.overrides?.localStorage,
123
+ });
120
124
  }
121
125
  /**
122
126
  * Use to handle a login response. Automatically stores the CSRF token.
@@ -126,17 +130,20 @@ export class FrontendAuthClient {
126
130
  */
127
131
  async handleLoginResponse(response) {
128
132
  if (!response.ok) {
129
- authLog('auth-vir: LOGOUT - handleLoginResponse: response not ok');
130
133
  await this.logout();
131
134
  throw new Error('Login response failed.');
132
135
  }
133
- const { csrfToken } = extractCsrfTokenHeader(response, this.config.overrides);
136
+ const { csrfToken } = extractCsrfTokenHeader(response, this.config.csrf, {
137
+ allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
138
+ });
134
139
  if (!csrfToken) {
135
- authLog('auth-vir: LOGOUT - handleLoginResponse: no CSRF token in response');
136
140
  await this.logout();
137
141
  throw new Error('Did not receive any CSRF token.');
138
142
  }
139
- storeCsrfToken(csrfToken, this.config.overrides);
143
+ storeCsrfToken(csrfToken, {
144
+ ...this.config.csrf,
145
+ localStorage: this.config.overrides?.localStorage,
146
+ });
140
147
  }
141
148
  /**
142
149
  * Use to verify _all_ responses received from the backend. Immediately logs the user out once
@@ -147,16 +154,18 @@ export class FrontendAuthClient {
147
154
  async verifyResponseAuth(response) {
148
155
  if (response.status === HttpStatus.Unauthorized &&
149
156
  !response.headers?.get(AuthHeaderName.IsSignUpAuth)) {
150
- authLog('auth-vir: LOGOUT - verifyResponseAuth: unauthorized response (401)', {
151
- status: response.status,
152
- });
153
157
  await this.logout();
154
158
  return false;
155
159
  }
156
160
  /** If the response has a new CSRF token, store it. */
157
- const { csrfToken } = extractCsrfTokenHeader(response, this.config.overrides);
161
+ const { csrfToken } = extractCsrfTokenHeader(response, this.config.csrf, {
162
+ allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
163
+ });
158
164
  if (csrfToken) {
159
- storeCsrfToken(csrfToken, this.config.overrides);
165
+ storeCsrfToken(csrfToken, {
166
+ ...this.config.csrf,
167
+ localStorage: this.config.overrides?.localStorage,
168
+ });
160
169
  }
161
170
  return true;
162
171
  }
@@ -0,0 +1,23 @@
1
+ import { type AnyDuration, type FullDate, type UtcTimezone } from 'date-vir';
2
+ /**
3
+ * Determines if enough time has passed since the JWT was issued to start refreshing the session.
4
+ *
5
+ * Visually, this check looks like this:
6
+ *
7
+ * I====R===========E
8
+ *
9
+ * - I = JWT issued time (from the JWT's `iat` claim)
10
+ * - R = session refreshing is available now (I + sessionRefreshStartTime)
11
+ * - E = JWT expiration
12
+ * - `=` between R and E = the time frame in which the return value is `true`.
13
+ *
14
+ * @category Auth : Host
15
+ */
16
+ export declare function isSessionRefreshReady({ now, jwtIssuedAt, sessionRefreshStartTime, }: {
17
+ /** The current time. */
18
+ now?: Readonly<FullDate<UtcTimezone>> | undefined;
19
+ /** When the JWT was issued (`iat` claim). */
20
+ jwtIssuedAt: Readonly<FullDate<UtcTimezone>>;
21
+ /** How long after JWT issuance before refreshing is available. */
22
+ sessionRefreshStartTime: Readonly<AnyDuration>;
23
+ }): boolean;
@@ -0,0 +1,21 @@
1
+ import { calculateRelativeDate, getNowInUtcTimezone, isDateAfter, } from 'date-vir';
2
+ /**
3
+ * Determines if enough time has passed since the JWT was issued to start refreshing the session.
4
+ *
5
+ * Visually, this check looks like this:
6
+ *
7
+ * I====R===========E
8
+ *
9
+ * - I = JWT issued time (from the JWT's `iat` claim)
10
+ * - R = session refreshing is available now (I + sessionRefreshStartTime)
11
+ * - E = JWT expiration
12
+ * - `=` between R and E = the time frame in which the return value is `true`.
13
+ *
14
+ * @category Auth : Host
15
+ */
16
+ export function isSessionRefreshReady({ now = getNowInUtcTimezone(), jwtIssuedAt, sessionRefreshStartTime, }) {
17
+ return isDateAfter({
18
+ fullDate: now,
19
+ relativeTo: calculateRelativeDate(jwtIssuedAt, sessionRefreshStartTime),
20
+ });
21
+ }
package/dist/auth.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type PartialWithUndefined } from '@augment-vir/common';
2
2
  import { type FullDate, type UtcTimezone } from 'date-vir';
3
3
  import { type CookieParams } from './cookie.js';
4
- import { AuthHeaderName } from './headers.js';
4
+ import { type CsrfHeaderNameOption } from './csrf-token.js';
5
5
  import { type ParseJwtParams } from './jwt/jwt.js';
6
6
  import { type JwtUserData } from './jwt/user-jwt.js';
7
7
  /**
@@ -18,7 +18,11 @@ export type HeaderContainer = Record<string, string[] | undefined | string | num
18
18
  export type UserIdResult<UserId extends string | number> = {
19
19
  userId: UserId;
20
20
  jwtExpiration: FullDate<UtcTimezone>;
21
+ /** When the JWT was issued (`iat` claim). */
22
+ jwtIssuedAt: FullDate<UtcTimezone>;
21
23
  cookieName: string;
24
+ /** The CSRF token embedded in the JWT. */
25
+ csrfToken: string;
22
26
  /**
23
27
  * Unix timestamp (in milliseconds) when the session was originally started. Used to enforce max
24
28
  * session duration.
@@ -33,9 +37,7 @@ export type UserIdResult<UserId extends string | number> = {
33
37
  * @category Auth : Host
34
38
  * @returns The extracted user id or `undefined` if no valid auth headers exist.
35
39
  */
36
- export declare function extractUserIdFromRequestHeaders<UserId extends string | number>(headers: HeaderContainer, jwtParams: Readonly<ParseJwtParams>, cookieName?: string, overrides?: PartialWithUndefined<{
37
- csrfHeaderName: string;
38
- }>): Promise<Readonly<UserIdResult<UserId>> | undefined>;
40
+ export declare function extractUserIdFromRequestHeaders<UserId extends string | number>(headers: HeaderContainer, jwtParams: Readonly<ParseJwtParams>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>, cookieName?: string): Promise<Readonly<UserIdResult<UserId>> | undefined>;
39
41
  /**
40
42
  * Extract a user id from just the cookie, without CSRF token validation. This is _less secure_ than
41
43
  * {@link extractUserIdFromRequestHeaders} as a result. This should only be used in rare
@@ -50,29 +52,21 @@ export declare function insecureExtractUserIdFromCookieAlone<UserId extends stri
50
52
  *
51
53
  * @category Auth : Host
52
54
  */
53
- export declare function generateSuccessfulLoginHeaders<CsrfHeaderName extends string = AuthHeaderName.CsrfToken>(
55
+ export declare function generateSuccessfulLoginHeaders(
54
56
  /** The id from your database of the user you're authenticating. */
55
- userId: string | number, cookieConfig: Readonly<CookieParams>, overrides?: PartialWithUndefined<{
56
- csrfHeaderName: CsrfHeaderName;
57
- }>,
57
+ userId: string | number, cookieConfig: Readonly<CookieParams>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>,
58
58
  /**
59
59
  * The timestamp (in seconds) when the session originally started. If not provided, the current
60
60
  * time will be used (for new sessions).
61
61
  */
62
- sessionStartedAt?: number | undefined): Promise<{
63
- 'set-cookie': string;
64
- } & Record<CsrfHeaderName, string>>;
62
+ sessionStartedAt?: number | undefined): Promise<Record<string, string>>;
65
63
  /**
66
64
  * Used by host (backend) code to set headers on a response object when the user has logged out or
67
65
  * failed to authorize.
68
66
  *
69
67
  * @category Auth : Host
70
68
  */
71
- export declare function generateLogoutHeaders<CsrfHeaderName extends string = AuthHeaderName.CsrfToken>(cookieConfig: Readonly<Pick<CookieParams, 'cookieName' | 'hostOrigin' | 'isDev'>>, overrides?: PartialWithUndefined<{
72
- csrfHeaderName: CsrfHeaderName;
73
- }>): {
74
- 'set-cookie': string;
75
- } & Record<CsrfHeaderName, string>;
69
+ export declare function generateLogoutHeaders(cookieConfig: Readonly<Pick<CookieParams, 'cookieName' | 'hostOrigin' | 'isDev'>>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>): Record<string, string>;
76
70
  /**
77
71
  * Store auth data on a client (frontend) after receiving an auth response from the host (backend).
78
72
  * Specifically, this stores the CSRF token into local storage (which doesn't need to be a secret).
@@ -82,13 +76,11 @@ export declare function generateLogoutHeaders<CsrfHeaderName extends string = Au
82
76
  * @category Auth : Client
83
77
  * @throws Error if no CSRF token header is found.
84
78
  */
85
- export declare function handleAuthResponse(response: Readonly<Pick<Response, 'ok' | 'headers'>>, overrides?: PartialWithUndefined<{
79
+ export declare function handleAuthResponse(response: Readonly<Pick<Response, 'ok' | 'headers'>>, options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
86
80
  /**
87
81
  * Allows mocking or overriding the global `localStorage`.
88
82
  *
89
83
  * @default globalThis.localStorage
90
84
  */
91
85
  localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
92
- /** Override the default CSRF token header name. */
93
- csrfHeaderName: string;
94
86
  }>): void;
package/dist/auth.js CHANGED
@@ -1,7 +1,5 @@
1
1
  import { AuthCookieName, clearAuthCookie, extractCookieJwt, generateAuthCookie, } from './cookie.js';
2
- import { extractCsrfTokenHeader, generateCsrfToken, parseCsrfToken, storeCsrfToken, wipeCurrentCsrfToken, } from './csrf-token.js';
3
- import { AuthHeaderName } from './headers.js';
4
- import { authLog } from './log.js';
2
+ import { extractCsrfTokenHeader, generateCsrfToken, parseCsrfToken, resolveCsrfHeaderName, storeCsrfToken, wipeCurrentCsrfToken, } from './csrf-token.js';
5
3
  function readHeader(headers, headerName) {
6
4
  if (headers instanceof Headers) {
7
5
  return headers.get(headerName) || undefined;
@@ -19,15 +17,12 @@ function readHeader(headers, headerName) {
19
17
  }
20
18
  }
21
19
  }
22
- function readCsrfTokenHeader(headers, overrides) {
23
- const rawCsrfToken = readHeader(headers, overrides.csrfHeaderName || AuthHeaderName.CsrfToken);
20
+ function readCsrfTokenHeader(headers, csrfHeaderNameOption) {
21
+ const rawCsrfToken = readHeader(headers, resolveCsrfHeaderName(csrfHeaderNameOption));
24
22
  if (!rawCsrfToken) {
25
23
  return undefined;
26
24
  }
27
25
  const token = parseCsrfToken(rawCsrfToken).csrfToken?.token || rawCsrfToken;
28
- if (!token) {
29
- authLog('auth-vir: CSRF token not found.');
30
- }
31
26
  return token;
32
27
  }
33
28
  /**
@@ -38,38 +33,27 @@ function readCsrfTokenHeader(headers, overrides) {
38
33
  * @category Auth : Host
39
34
  * @returns The extracted user id or `undefined` if no valid auth headers exist.
40
35
  */
41
- export async function extractUserIdFromRequestHeaders(headers, jwtParams, cookieName = AuthCookieName.Auth, overrides = {}) {
36
+ export async function extractUserIdFromRequestHeaders(headers, jwtParams, csrfHeaderNameOption, cookieName = AuthCookieName.Auth) {
42
37
  try {
43
- const csrfToken = readCsrfTokenHeader(headers, overrides);
38
+ const csrfToken = readCsrfTokenHeader(headers, csrfHeaderNameOption);
44
39
  const cookie = readHeader(headers, 'cookie');
45
40
  if (!cookie || !csrfToken) {
46
- authLog('auth-vir: extractUserIdFromRequestHeaders failed - missing cookie or CSRF token', {
47
- hasCookie: !!cookie,
48
- hasCsrfToken: !!csrfToken,
49
- cookieName,
50
- });
51
41
  return undefined;
52
42
  }
53
43
  const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
54
44
  if (!jwt || jwt.data.csrfToken !== csrfToken) {
55
- if (cookieName === AuthCookieName.Auth) {
56
- authLog('auth-vir: extractUserIdFromRequestHeaders failed - JWT invalid or CSRF mismatch', {
57
- hasJwt: !!jwt,
58
- csrfMatch: jwt ? jwt.data.csrfToken === csrfToken : false,
59
- cookieName,
60
- });
61
- }
62
45
  return undefined;
63
46
  }
64
47
  return {
65
48
  userId: jwt.data.userId,
66
49
  jwtExpiration: jwt.jwtExpiration,
50
+ jwtIssuedAt: jwt.jwtIssuedAt,
67
51
  cookieName,
52
+ csrfToken: jwt.data.csrfToken,
68
53
  sessionStartedAt: jwt.data.sessionStartedAt,
69
54
  };
70
55
  }
71
- catch (error) {
72
- authLog('auth-vir: extractUserIdFromRequestHeaders error', { error, cookieName });
56
+ catch {
73
57
  return undefined;
74
58
  }
75
59
  }
@@ -85,23 +69,22 @@ export async function insecureExtractUserIdFromCookieAlone(headers, jwtParams, c
85
69
  try {
86
70
  const cookie = readHeader(headers, 'cookie');
87
71
  if (!cookie) {
88
- authLog('auth-vir: insecureExtractUserIdFromCookieAlone failed - no cookie');
89
72
  return undefined;
90
73
  }
91
74
  const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
92
75
  if (!jwt) {
93
- authLog('auth-vir: insecureExtractUserIdFromCookieAlone failed - JWT extraction failed');
94
76
  return undefined;
95
77
  }
96
78
  return {
97
79
  userId: jwt.data.userId,
98
80
  jwtExpiration: jwt.jwtExpiration,
81
+ jwtIssuedAt: jwt.jwtIssuedAt,
99
82
  cookieName,
83
+ csrfToken: jwt.data.csrfToken,
100
84
  sessionStartedAt: jwt.data.sessionStartedAt,
101
85
  };
102
86
  }
103
- catch (error) {
104
- authLog('auth-vir: insecureExtractUserIdFromCookieAlone error', { error });
87
+ catch {
105
88
  return undefined;
106
89
  }
107
90
  }
@@ -112,21 +95,25 @@ export async function insecureExtractUserIdFromCookieAlone(headers, jwtParams, c
112
95
  */
113
96
  export async function generateSuccessfulLoginHeaders(
114
97
  /** The id from your database of the user you're authenticating. */
115
- userId, cookieConfig, overrides = {},
98
+ userId, cookieConfig, csrfHeaderNameOption,
116
99
  /**
117
100
  * The timestamp (in seconds) when the session originally started. If not provided, the current
118
101
  * time will be used (for new sessions).
119
102
  */
120
103
  sessionStartedAt) {
121
104
  const csrfToken = generateCsrfToken(cookieConfig.cookieDuration);
122
- const csrfHeaderName = (overrides.csrfHeaderName || AuthHeaderName.CsrfToken);
105
+ const csrfHeaderName = resolveCsrfHeaderName(csrfHeaderNameOption);
106
+ const { cookie, expiration } = await generateAuthCookie({
107
+ csrfToken: csrfToken.token,
108
+ userId,
109
+ sessionStartedAt: sessionStartedAt ?? Date.now(),
110
+ }, cookieConfig);
123
111
  return {
124
- 'set-cookie': await generateAuthCookie({
125
- csrfToken: csrfToken.token,
126
- userId,
127
- sessionStartedAt: sessionStartedAt ?? Date.now(),
128
- }, cookieConfig),
129
- [csrfHeaderName]: JSON.stringify(csrfToken),
112
+ 'set-cookie': cookie,
113
+ [csrfHeaderName]: JSON.stringify({
114
+ token: csrfToken.token,
115
+ expiration,
116
+ }),
130
117
  };
131
118
  }
132
119
  /**
@@ -135,11 +122,8 @@ sessionStartedAt) {
135
122
  *
136
123
  * @category Auth : Host
137
124
  */
138
- export function generateLogoutHeaders(cookieConfig, overrides = {}) {
139
- authLog('auth-vir: LOGOUT - generateLogoutHeaders called', {
140
- cookieName: cookieConfig.cookieName,
141
- }, new Error().stack);
142
- const csrfHeaderName = (overrides.csrfHeaderName || AuthHeaderName.CsrfToken);
125
+ export function generateLogoutHeaders(cookieConfig, csrfHeaderNameOption) {
126
+ const csrfHeaderName = resolveCsrfHeaderName(csrfHeaderNameOption);
143
127
  return {
144
128
  'set-cookie': clearAuthCookie(cookieConfig),
145
129
  [csrfHeaderName]: 'redacted',
@@ -154,18 +138,15 @@ export function generateLogoutHeaders(cookieConfig, overrides = {}) {
154
138
  * @category Auth : Client
155
139
  * @throws Error if no CSRF token header is found.
156
140
  */
157
- export function handleAuthResponse(response, overrides = {}) {
141
+ export function handleAuthResponse(response, options) {
158
142
  if (!response.ok) {
159
- authLog('auth-vir: LOGOUT - handleAuthResponse: response not ok, wiping CSRF token');
160
- wipeCurrentCsrfToken(overrides);
143
+ wipeCurrentCsrfToken(options);
161
144
  return;
162
145
  }
163
- const { csrfToken } = extractCsrfTokenHeader(response, overrides);
146
+ const { csrfToken } = extractCsrfTokenHeader(response, options);
164
147
  if (!csrfToken) {
165
- authLog('auth-vir: LOGOUT - handleAuthResponse: no CSRF token in response, wiping');
166
- wipeCurrentCsrfToken(overrides);
148
+ wipeCurrentCsrfToken(options);
167
149
  throw new Error('Did not receive any CSRF token.');
168
150
  }
169
- authLog('auth-vir: handleAuthResponse - successfully stored CSRF token');
170
- storeCsrfToken(csrfToken, overrides);
151
+ storeCsrfToken(csrfToken, options);
171
152
  }
package/dist/cookie.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type PartialWithUndefined } from '@augment-vir/common';
2
- import { type AnyDuration } from 'date-vir';
2
+ import { type AnyDuration, type FullDate, type UtcTimezone } from 'date-vir';
3
3
  import { type Primitive } from 'type-fest';
4
4
  import { type CreateJwtParams, type ParseJwtParams, type ParsedJwt } from './jwt/jwt.js';
5
5
  import { type JwtUserData } from './jwt/user-jwt.js';
@@ -48,12 +48,21 @@ export type CookieParams = {
48
48
  */
49
49
  isDev: boolean;
50
50
  }>;
51
+ /**
52
+ * Output from {@link generateAuthCookie}.
53
+ *
54
+ * @category Internal
55
+ */
56
+ export type GenerateAuthCookieResult = {
57
+ cookie: string;
58
+ expiration: FullDate<UtcTimezone>;
59
+ };
51
60
  /**
52
61
  * Generate a secure cookie that stores the user JWT data. Used in host (backend) code.
53
62
  *
54
63
  * @category Internal
55
64
  */
56
- export declare function generateAuthCookie(userJwtData: Readonly<JwtUserData>, cookieConfig: Readonly<CookieParams>): Promise<string>;
65
+ export declare function generateAuthCookie(userJwtData: Readonly<JwtUserData>, cookieConfig: Readonly<CookieParams>): Promise<GenerateAuthCookieResult>;
57
66
  /**
58
67
  * Generate a cookie value that will clear the previous auth cookie. Use this when signing out.
59
68
  *
package/dist/cookie.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { check } from '@augment-vir/assert';
2
- import { safeMatch } from '@augment-vir/common';
3
- import { convertDuration } from 'date-vir';
2
+ import { escapeStringForRegExp, safeMatch } from '@augment-vir/common';
3
+ import { calculateRelativeDate, convertDuration, getNowInUtcTimezone, } from 'date-vir';
4
4
  import { parseUrl } from 'url-vir';
5
5
  import { createUserJwt, parseUserJwt } from './jwt/user-jwt.js';
6
6
  /**
@@ -21,15 +21,19 @@ export var AuthCookieName;
21
21
  * @category Internal
22
22
  */
23
23
  export async function generateAuthCookie(userJwtData, cookieConfig) {
24
- return generateCookie({
25
- [cookieConfig.cookieName || 'auth']: await createUserJwt(userJwtData, cookieConfig.jwtParams),
26
- Domain: parseUrl(cookieConfig.hostOrigin).hostname,
27
- HttpOnly: true,
28
- Path: '/',
29
- SameSite: 'Strict',
30
- 'MAX-AGE': convertDuration(cookieConfig.cookieDuration, { seconds: true }).seconds,
31
- Secure: !cookieConfig.isDev,
32
- });
24
+ const expiration = calculateRelativeDate(getNowInUtcTimezone(), cookieConfig.cookieDuration);
25
+ return {
26
+ cookie: generateCookie({
27
+ [cookieConfig.cookieName || 'auth']: await createUserJwt(userJwtData, cookieConfig.jwtParams),
28
+ Domain: parseUrl(cookieConfig.hostOrigin).hostname,
29
+ HttpOnly: true,
30
+ Path: '/',
31
+ SameSite: 'Strict',
32
+ 'MAX-AGE': convertDuration(cookieConfig.cookieDuration, { seconds: true }).seconds,
33
+ Secure: !cookieConfig.isDev,
34
+ }),
35
+ expiration,
36
+ };
33
37
  }
34
38
  /**
35
39
  * Generate a cookie value that will clear the previous auth cookie. Use this when signing out.
@@ -78,7 +82,7 @@ export function generateCookie(params) {
78
82
  * @returns The extracted auth Cookie JWT data or `undefined` if no valid auth JWT data was found.
79
83
  */
80
84
  export async function extractCookieJwt(rawCookie, jwtParams, cookieName = AuthCookieName.Auth) {
81
- const cookieRegExp = new RegExp(`${cookieName}=[^;]+(?:;|$)`);
85
+ const cookieRegExp = new RegExp(`${escapeStringForRegExp(cookieName)}=[^;]+(?:;|$)`);
82
86
  const [cookieValue] = safeMatch(rawCookie, cookieRegExp);
83
87
  if (!cookieValue) {
84
88
  return undefined;
@@ -27,6 +27,14 @@ export declare const csrfTokenShape: import("object-shape-tester").Shape<{
27
27
  * @category Internal
28
28
  */
29
29
  export type CsrfToken = typeof csrfTokenShape.runtimeType;
30
+ /**
31
+ * Default allowed clock skew for CSRF token expiration checks. Accounts for differences between
32
+ * server and client clocks when checking token expiration.
33
+ *
34
+ * @category Internal
35
+ * @default {minutes: 5}
36
+ */
37
+ export declare const defaultAllowedClockSkew: Readonly<AnyDuration>;
30
38
  /**
31
39
  * Generates a random, cryptographically secure CSRF token.
32
40
  *
@@ -48,6 +56,25 @@ export declare enum CsrfTokenFailureReason {
48
56
  /** A CSRF token was found and parsed but is expired. */
49
57
  Expired = "expired"
50
58
  }
59
+ /**
60
+ * Options for specifying the CSRF token header name.
61
+ *
62
+ * @category Auth : Client
63
+ * @category Auth : Host
64
+ */
65
+ export type CsrfHeaderNameOption = RequireExactlyOne<{
66
+ /** Prefix used to generate the header name: `${prefix}-auth-vir-csrf-token`. */
67
+ csrfHeaderPrefix: string;
68
+ /** Overrides the entire CSRF header name. */
69
+ csrfHeaderName: string;
70
+ }>;
71
+ /**
72
+ * Resolves a {@link CsrfHeaderNameOption} to the actual header name string.
73
+ *
74
+ * @category Auth : Client
75
+ * @category Auth : Host
76
+ */
77
+ export declare function resolveCsrfHeaderName(option: Readonly<CsrfHeaderNameOption>): string;
51
78
  /**
52
79
  * Output from {@link getCurrentCsrfToken}.
53
80
  *
@@ -64,45 +91,60 @@ export type GetCsrfTokenResult = RequireExactlyOne<{
64
91
  */
65
92
  export declare function extractCsrfTokenHeader(response: Readonly<PartialWithUndefined<SelectFrom<Response, {
66
93
  headers: true;
67
- }>>>, overrides?: PartialWithUndefined<{
68
- csrfHeaderName: string;
94
+ }>>>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>, options?: PartialWithUndefined<{
95
+ /**
96
+ * Allowed clock skew tolerance for CSRF token expiration checks.
97
+ *
98
+ * @default {minutes: 5}
99
+ */
100
+ allowedClockSkew: Readonly<AnyDuration>;
69
101
  }>): Readonly<GetCsrfTokenResult>;
70
102
  /**
71
103
  * Stores the given CSRF token into local storage.
72
104
  *
73
105
  * @category Auth : Client
74
106
  */
75
- export declare function storeCsrfToken(csrfToken: Readonly<CsrfToken>, overrides?: PartialWithUndefined<{
107
+ export declare function storeCsrfToken(csrfToken: Readonly<CsrfToken>, options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
76
108
  /**
77
109
  * Allows mocking or overriding the global `localStorage`.
78
110
  *
79
111
  * @default globalThis.localStorage
80
112
  */
81
113
  localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
82
- /** Override the default CSRF token header name. */
83
- csrfHeaderName: string;
84
114
  }>): void;
85
115
  /**
86
116
  * Parse a raw CSRF token JSON string.
87
117
  *
88
118
  * @category Internal
89
119
  */
90
- export declare function parseCsrfToken(value: string | undefined | null): Readonly<GetCsrfTokenResult>;
120
+ export declare function parseCsrfToken(value: string | undefined | null, options?: PartialWithUndefined<{
121
+ /**
122
+ * Allowed clock skew tolerance for CSRF token expiration checks. Accounts for differences
123
+ * between server and client clocks.
124
+ *
125
+ * @default {minutes: 5}
126
+ */
127
+ allowedClockSkew: Readonly<AnyDuration>;
128
+ }>): Readonly<GetCsrfTokenResult>;
91
129
  /**
92
130
  * Used in client (frontend) code to retrieve the current CSRF token in order to send it with
93
131
  * requests to the host (backend).
94
132
  *
95
133
  * @category Auth : Client
96
134
  */
97
- export declare function getCurrentCsrfToken(overrides?: PartialWithUndefined<{
135
+ export declare function getCurrentCsrfToken(options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
98
136
  /**
99
137
  * Allows mocking or overriding the global `localStorage`.
100
138
  *
101
139
  * @default globalThis.localStorage
102
140
  */
103
141
  localStorage: Pick<Storage, 'getItem'>;
104
- /** Override the default CSRF token header name. */
105
- csrfHeaderName: string;
142
+ /**
143
+ * Allowed clock skew tolerance for CSRF token expiration checks.
144
+ *
145
+ * @default {minutes: 5}
146
+ */
147
+ allowedClockSkew: Readonly<AnyDuration>;
106
148
  }>): Readonly<GetCsrfTokenResult>;
107
149
  /**
108
150
  * Wipes the current stored CSRF token. This should be used by client (frontend) code to logout a
@@ -110,13 +152,11 @@ export declare function getCurrentCsrfToken(overrides?: PartialWithUndefined<{
110
152
  *
111
153
  * @category Auth : Client
112
154
  */
113
- export declare function wipeCurrentCsrfToken(overrides?: PartialWithUndefined<{
155
+ export declare function wipeCurrentCsrfToken(options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
114
156
  /**
115
157
  * Allows mocking or overriding the global `localStorage`.
116
158
  *
117
159
  * @default globalThis.localStorage
118
160
  */
119
161
  localStorage: Pick<Storage, 'removeItem'>;
120
- /** Override the default CSRF token header name. */
121
- csrfHeaderName: string;
122
162
  }>): void;