auth-vir 0.0.0 → 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/src/jwt.ts ADDED
@@ -0,0 +1,160 @@
1
+ import {check} from '@augment-vir/assert';
2
+ import type {AnyObject, PartialWithUndefined} from '@augment-vir/common';
3
+ import {
4
+ calculateRelativeDate,
5
+ createFullDateInUserTimezone,
6
+ getNowInUtcTimezone,
7
+ toTimestamp,
8
+ type AnyDuration,
9
+ type DateLike,
10
+ } from 'date-vir';
11
+ import {EncryptJWT, jwtDecrypt, jwtVerify, SignJWT} from 'jose';
12
+ import {JwtKeys} from './jwt-keys.js';
13
+
14
+ const encryptionProtectedHeader = {alg: 'dir', enc: 'A256GCM'};
15
+ const signingProtectedHeader = {alg: 'HS512'};
16
+
17
+ /**
18
+ * Params for {@link createJwt}.
19
+ *
20
+ * @category Internal
21
+ */
22
+ export type CreateJwtParams = Readonly<{
23
+ /**
24
+ * The keys required to sign and encrypt the JWT.
25
+ *
26
+ * These keys should be kept secret and never shared with any frontend, client, etc.
27
+ */
28
+ jwtKeys: Readonly<JwtKeys>;
29
+ /**
30
+ * The name of the company, the name of the service, or the URL to the service that originally
31
+ * issued the JWT. The same value must be used when creating and parsing a JWT or the parse will
32
+ * fail.
33
+ *
34
+ * This name can be anything you want.
35
+ *
36
+ * @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
37
+ */
38
+ issuer: string;
39
+ /**
40
+ * The arbitrary name or URL of the client intended to consume the JWT. The host and client must
41
+ * both know this name in order for the token to be signed and read correctly.
42
+ *
43
+ * This name can be anything you want.
44
+ *
45
+ * @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
46
+ */
47
+ audience: string;
48
+ /**
49
+ * The duration until the JWT expires.
50
+ *
51
+ * @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
52
+ */
53
+ jwtDuration: Readonly<AnyDuration>;
54
+ }> &
55
+ Readonly<
56
+ PartialWithUndefined<{
57
+ /**
58
+ * Set a custom issued at date.
59
+ *
60
+ * This should usually not be overridden.
61
+ *
62
+ * @default Date.now()
63
+ * @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
64
+ */
65
+ issuedAt: DateLike;
66
+ /**
67
+ * Set a custom date for when the JWT will become valid. The JWT will be considered
68
+ * invalid and not be processed until this date.
69
+ *
70
+ * This should usually not be overridden.
71
+ *
72
+ * @default
73
+ * none, the JWT will be immediately valid
74
+ * @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
75
+ */
76
+ notValidUntil: DateLike;
77
+ }>
78
+ >;
79
+
80
+ /**
81
+ * Creates a signed and encrypted JWT that contains the given data.
82
+ *
83
+ * @category Internal
84
+ */
85
+ export async function createJwt<JwtData extends AnyObject = AnyObject>(
86
+ /** The data to be included in the JWT. */
87
+ data: JwtData,
88
+ params: Readonly<CreateJwtParams>,
89
+ ): Promise<string> {
90
+ const rawJwt = new SignJWT({data: data})
91
+ .setProtectedHeader(signingProtectedHeader)
92
+ .setIssuedAt(
93
+ params.issuedAt
94
+ ? toTimestamp(createFullDateInUserTimezone(params.issuedAt))
95
+ : undefined,
96
+ )
97
+ .setIssuer(params.issuer)
98
+ .setAudience(params.audience)
99
+ .setExpirationTime(
100
+ toTimestamp(calculateRelativeDate(getNowInUtcTimezone(), params.jwtDuration)),
101
+ );
102
+
103
+ if (params.notValidUntil) {
104
+ rawJwt.setNotBefore(toTimestamp(createFullDateInUserTimezone(params.notValidUntil)));
105
+ }
106
+
107
+ const signedJwt = await rawJwt.sign(params.jwtKeys.signingKey);
108
+
109
+ return await new EncryptJWT({jwt: signedJwt})
110
+ .setProtectedHeader(encryptionProtectedHeader)
111
+ .encrypt(params.jwtKeys.encryptionKey);
112
+ }
113
+
114
+ /**
115
+ * Params for {@link parseJwt}.
116
+ *
117
+ * @category Internal
118
+ */
119
+ export type ParseJwtParams = Readonly<Pick<CreateJwtParams, 'issuer' | 'audience' | 'jwtKeys'>>;
120
+
121
+ /**
122
+ * Parse and extract all data from an encrypted and signed JWT.
123
+ *
124
+ * @category Internal
125
+ * @throws Errors if the decryption, signature verification, or other JWT requirements fail
126
+ */
127
+ export async function parseJwt<JwtData extends AnyObject = AnyObject>(
128
+ encryptedJwt: string,
129
+ params: Readonly<ParseJwtParams>,
130
+ ): Promise<JwtData> {
131
+ const decryptedJwt = await jwtDecrypt(encryptedJwt, params.jwtKeys.encryptionKey);
132
+
133
+ if (!check.deepEquals(decryptedJwt.protectedHeader, encryptionProtectedHeader)) {
134
+ throw new Error('Invalid encryption protected header.');
135
+ } else if (!check.isString(decryptedJwt.payload.jwt)) {
136
+ throw new TypeError('Decrypted jwt is not a string.');
137
+ }
138
+
139
+ const verifiedJwt = await jwtVerify(decryptedJwt.payload.jwt, params.jwtKeys.signingKey, {
140
+ issuer: params.issuer,
141
+ audience: params.audience,
142
+ requiredClaims: [
143
+ 'iat',
144
+ 'aud',
145
+ 'iss',
146
+ ],
147
+ });
148
+
149
+ if (!verifiedJwt.payload.iat || verifiedJwt.payload.iat * 1000 > Date.now()) {
150
+ throw new Error('"iat" claim timestamp check failed');
151
+ }
152
+
153
+ const data = verifiedJwt.payload.data;
154
+
155
+ if (!check.deepEquals(verifiedJwt.protectedHeader, signingProtectedHeader)) {
156
+ throw new Error('Invalid signing protected header.');
157
+ }
158
+
159
+ return data as JwtData;
160
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `accessRecord` type for {@link createMockLocalStorage}'s output.
3
+ *
4
+ * @category Internal
5
+ */
6
+ export type MockLocalStorageAccessRecord = {
7
+ getItem: string[];
8
+ removeItem: string[];
9
+ setItem: {key: string; value: string}[];
10
+ key: number[];
11
+ };
12
+
13
+ /**
14
+ * Create an empty `accessRecord` object, this is to be used in conjunction with
15
+ * {@link createMockLocalStorage}.
16
+ *
17
+ * @category Mock
18
+ */
19
+ export function createEmptyMockLocalStorageAccessRecord(): MockLocalStorageAccessRecord {
20
+ return {
21
+ getItem: [],
22
+ removeItem: [],
23
+ setItem: [],
24
+ key: [],
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Create a LocalStorage mock.
30
+ *
31
+ * @category Mock
32
+ */
33
+ export function createMockLocalStorage(
34
+ /** Set values in here to initialize the mocked localStorage data store contents. */
35
+ init: Record<string, string> = {},
36
+ ) {
37
+ const store: Record<string, string> = init;
38
+ const accessRecord = createEmptyMockLocalStorageAccessRecord();
39
+
40
+ const mockLocalStorage: Storage = {
41
+ clear() {
42
+ Object.keys(store).forEach((key) => {
43
+ delete store[key];
44
+ });
45
+ },
46
+ getItem(key) {
47
+ accessRecord.getItem.push(key);
48
+ return store[key] ?? null;
49
+ },
50
+ get length() {
51
+ return Object.keys(store).length;
52
+ },
53
+ key(index) {
54
+ accessRecord.key.push(index);
55
+ return Object.keys(store)[index] ?? null;
56
+ },
57
+ removeItem(key) {
58
+ accessRecord.removeItem.push(key);
59
+ delete store[key];
60
+ },
61
+ setItem(key, value) {
62
+ accessRecord.setItem.push({key, value});
63
+ store[key] = value;
64
+ },
65
+ };
66
+
67
+ return {
68
+ localStorage: mockLocalStorage,
69
+ store,
70
+ accessRecord,
71
+ };
72
+ }
@@ -0,0 +1,60 @@
1
+ import {defineShape, isValidShape} from 'object-shape-tester';
2
+ import {type generateCsrfToken} from './csrf-token.js';
3
+ import {createJwt, CreateJwtParams, parseJwt, ParseJwtParams} from './jwt.js';
4
+
5
+ /**
6
+ * Shape definition and source of truth for {@link UserJwtData}.
7
+ *
8
+ * @category Internal
9
+ */
10
+ export const userJwtDataShape = defineShape({
11
+ /** The id from your database of the user you're authenticating. */
12
+ userId: '',
13
+ /**
14
+ * CSRF token. This can be any cryptographically secure randomized string.
15
+ *
16
+ * Consider using {@link generateCsrfToken} to generate this.
17
+ */
18
+ csrfToken: '',
19
+ });
20
+
21
+ /**
22
+ * Data required for user JWTs.
23
+ *
24
+ * @category Internal
25
+ */
26
+ export type UserJwtData = typeof userJwtDataShape.runtimeType;
27
+
28
+ /**
29
+ * Creates a new signed and encrypted {@link UserJwtData} when a client (frontend) successfully
30
+ * authenticates with the host (backend). This is used by host (backend) code to establish a new
31
+ * user session. The output of this function should be sent to the client (frontend) for storage.
32
+ *
33
+ * @category Internal
34
+ */
35
+ export async function createUserJwt(
36
+ data: Readonly<UserJwtData>,
37
+ params: Readonly<CreateJwtParams>,
38
+ ): Promise<string> {
39
+ return await createJwt(data, params);
40
+ }
41
+
42
+ /**
43
+ * Parses a {@link UserJwtData} generated from {@link createUserJwt}. This should be used on the host
44
+ * (backend) to a client (frontend) request. Do not use this function in client (frontend) code: it
45
+ * requires JWT signing keys which should not be shared with any client (frontend).
46
+ *
47
+ * @category Internal
48
+ */
49
+ export async function parseUserJwt(
50
+ encryptedJwt: string,
51
+ params: Readonly<ParseJwtParams>,
52
+ ): Promise<UserJwtData | undefined> {
53
+ const parsed = await parseJwt(encryptedJwt, params);
54
+
55
+ if (!isValidShape(parsed, userJwtDataShape)) {
56
+ throw new TypeError('Verified jwt has wrong data.');
57
+ }
58
+
59
+ return parsed;
60
+ }