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/package.json CHANGED
@@ -1,13 +1,68 @@
1
1
  {
2
- "name": "auth-vir",
3
- "version": "0.0.0",
4
- "description": "",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "keywords": [],
10
- "author": "",
11
- "license": "MIT",
12
- "type": "commonjs"
2
+ "name": "auth-vir",
3
+ "version": "0.0.1",
4
+ "description": "Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.",
5
+ "keywords": [
6
+ "auth",
7
+ "jwt",
8
+ "csrf",
9
+ "hash",
10
+ "password",
11
+ "secure",
12
+ "cookie"
13
+ ],
14
+ "homepage": "https://github.com/electrovir/auth-vir",
15
+ "bugs": {
16
+ "url": "https://github.com/electrovir/auth-vir/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/electrovir/auth-vir.git"
21
+ },
22
+ "license": "(MIT or CC0 1.0)",
23
+ "author": {
24
+ "name": "electrovir",
25
+ "url": "https://github.com/electrovir"
26
+ },
27
+ "type": "module",
28
+ "main": "dist/index.js",
29
+ "module": "dist/index.js",
30
+ "types": "dist/index.d.ts",
31
+ "bin": "",
32
+ "scripts": {
33
+ "build": "virmator frontend build",
34
+ "compile": "virmator compile",
35
+ "docs": "virmator docs",
36
+ "start": "virmator frontend",
37
+ "test": "virmator test web",
38
+ "test:coverage": "npm run test coverage",
39
+ "test:docs": "virmator docs check",
40
+ "test:update": "npm test update"
41
+ },
42
+ "dependencies": {
43
+ "@augment-vir/assert": "^31.11.0",
44
+ "@augment-vir/common": "^31.11.0",
45
+ "date-vir": "^7.3.1",
46
+ "hash-wasm": "^4.12.0",
47
+ "jose": "^6.0.10",
48
+ "object-shape-tester": "^5.1.5",
49
+ "url-vir": "^2.1.3"
50
+ },
51
+ "devDependencies": {
52
+ "@augment-vir/test": "^31.11.0",
53
+ "@web/dev-server-esbuild": "^1.0.4",
54
+ "@web/test-runner": "^0.20.1",
55
+ "@web/test-runner-commands": "^0.9.0",
56
+ "@web/test-runner-playwright": "^0.11.0",
57
+ "@web/test-runner-visual-regression": "^0.10.0",
58
+ "istanbul-smart-text-reporter": "^1.1.5",
59
+ "markdown-code-example-inserter": "^3.0.3",
60
+ "typedoc": "^0.28.2"
61
+ },
62
+ "engines": {
63
+ "node": ">=22"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
13
68
  }
package/src/auth.ts ADDED
@@ -0,0 +1,168 @@
1
+ import {CookieParams, extractCookieJwt, generateCookie} from './cookie.js';
2
+ import {csrfTokenHeaderName, generateCsrfToken} from './csrf-token.js';
3
+ import {ParseJwtParams} from './jwt.js';
4
+
5
+ /**
6
+ * All possible headers container types supported by {@link extractUserIdFromRequestHeaders}.
7
+ *
8
+ * @category Internal
9
+ */
10
+ export type HeaderContainer = Record<string, string[] | undefined | string | number> | Headers;
11
+
12
+ function readHeader(headers: HeaderContainer, headerName: string): string | undefined {
13
+ if (headers instanceof Headers) {
14
+ return headers.get(headerName) || undefined;
15
+ } else {
16
+ const value = headers[headerName];
17
+
18
+ if (value == undefined) {
19
+ return undefined;
20
+ } else if (Array.isArray(value)) {
21
+ return value[0];
22
+ } else {
23
+ return String(value);
24
+ }
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Extract the user id from a request by checking both the request cookie and CSRF token. This is
30
+ * used by host (backend) code to help verify a request. After extracting the user id using this,
31
+ * you should compare it to users stored in your database.
32
+ *
33
+ * @category Auth : Host
34
+ * @returns The extracted user id or `undefined` if no valid auth headers exist.
35
+ */
36
+ export async function extractUserIdFromRequestHeaders(
37
+ headers: HeaderContainer,
38
+ jwtParams: Readonly<ParseJwtParams>,
39
+ ): Promise<string | undefined> {
40
+ try {
41
+ const csrfToken = readHeader(headers, csrfTokenHeaderName);
42
+ const cookie = readHeader(headers, 'cookie');
43
+
44
+ if (!cookie || !csrfToken) {
45
+ return undefined;
46
+ }
47
+
48
+ const jwt = await extractCookieJwt(cookie, jwtParams);
49
+
50
+ if (!jwt || jwt.csrfToken !== csrfToken) {
51
+ return undefined;
52
+ }
53
+
54
+ return jwt.userId;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Used by host (backend) code to set headers on a response object.
62
+ *
63
+ * @category Auth : Host
64
+ */
65
+ export async function generateSuccessfulLoginHeaders(
66
+ userId: string,
67
+ /** The id from your database of the user you're authenticating. */
68
+ cookieConfig: Readonly<CookieParams>,
69
+ ) {
70
+ const csrfToken = generateCsrfToken();
71
+
72
+ return {
73
+ 'set-cookie': await generateCookie(
74
+ {
75
+ csrfToken,
76
+ userId,
77
+ },
78
+ cookieConfig,
79
+ ),
80
+ [csrfTokenHeaderName]: csrfToken,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Store auth data on a client (frontend) after receiving an auth response from the host (backend).
86
+ * Specifically, this stores the CSRF token into local storage (which doesn't need to be a secret).
87
+ * Alternatively, if the given response failed, this will wipe the existing (if anyone) stored CSRF
88
+ * token.
89
+ *
90
+ * @category Auth : Client
91
+ * @throws Error if no CSRF token header is found.
92
+ */
93
+ export function handleAuthResponse(
94
+ response: Readonly<Pick<Response, 'ok' | 'headers'>>,
95
+ overrides: {
96
+ /**
97
+ * Allows mocking or overriding the global `localStorage`.
98
+ *
99
+ * @default globalThis.localStorage
100
+ */
101
+ localStorage?: Pick<Storage, 'setItem' | 'removeItem'>;
102
+ /** Override the default CSRF token header name. */
103
+ csrfHeaderName?: string;
104
+ } = {},
105
+ ) {
106
+ if (!response.ok) {
107
+ wipeCurrentCsrfToken(overrides);
108
+ return;
109
+ }
110
+ const headerName = overrides.csrfHeaderName || csrfTokenHeaderName;
111
+
112
+ const csrfToken = response.headers.get(headerName);
113
+
114
+ if (!csrfToken) {
115
+ wipeCurrentCsrfToken(overrides);
116
+ throw new Error('Did not receive any CSRF token.');
117
+ }
118
+
119
+ (overrides.localStorage || globalThis.localStorage).setItem(headerName, csrfToken);
120
+ }
121
+
122
+ /**
123
+ * Used in client (frontend) code to retrieve the current CSRF token in order to send it with
124
+ * requests to the host (backend).
125
+ *
126
+ * @category Auth : Client
127
+ */
128
+ export function getCurrentCsrfToken(
129
+ overrides: {
130
+ /**
131
+ * Allows mocking or overriding the global `localStorage`.
132
+ *
133
+ * @default globalThis.localStorage
134
+ */
135
+ localStorage?: Pick<Storage, 'getItem'>;
136
+ /** Override the default CSRF token header name. */
137
+ csrfHeaderName?: string;
138
+ } = {},
139
+ ): string | undefined {
140
+ return (
141
+ (overrides.localStorage || globalThis.localStorage).getItem(
142
+ overrides.csrfHeaderName || csrfTokenHeaderName,
143
+ ) || undefined
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Wipes the current stored CSRF token. This should be used by client (frontend) code to logout a
149
+ * user or react to a session timeout.
150
+ *
151
+ * @category Auth : Client
152
+ */
153
+ export function wipeCurrentCsrfToken(
154
+ overrides: {
155
+ /**
156
+ * Allows mocking or overriding the global `localStorage`.
157
+ *
158
+ * @default globalThis.localStorage
159
+ */
160
+ localStorage?: Pick<Storage, 'removeItem'>;
161
+ /** Override the default CSRF token header name. */
162
+ csrfHeaderName?: string;
163
+ } = {},
164
+ ) {
165
+ return (overrides.localStorage || globalThis.localStorage).removeItem(
166
+ overrides.csrfHeaderName || csrfTokenHeaderName,
167
+ );
168
+ }
package/src/cookie.ts ADDED
@@ -0,0 +1,85 @@
1
+ import {check} from '@augment-vir/assert';
2
+ import {safeMatch, type PartialWithUndefined} from '@augment-vir/common';
3
+ import {convertDuration, type AnyDuration} from 'date-vir';
4
+ import {parseUrl} from 'url-vir';
5
+ import {CreateJwtParams, type ParseJwtParams} from './jwt.js';
6
+ import {createUserJwt, parseUserJwt, UserJwtData} from './user-jwt.js';
7
+
8
+ /**
9
+ * Parameters for {@link generateCookie}.
10
+ *
11
+ * @category Internal
12
+ */
13
+ export type CookieParams = {
14
+ /**
15
+ * The origin of the host (backend) service that cookies will be included in all requests to.
16
+ * This should be restricted to just your host (backend) origin for security purposes.
17
+ *
18
+ * @example 'https://www.example.com'
19
+ */
20
+ hostOrigin: string;
21
+ /**
22
+ * The max duration of this cookie. Or, in other words, the max user session duration before
23
+ * they're logged out.
24
+ */
25
+ cookieDuration: AnyDuration;
26
+ /**
27
+ * All JWT parameters required for generating the encrypted JWT that will be embedded in the
28
+ * Cookie. Note that all JWT keys contained herein should never shared with any frontend,
29
+ * client, etc.
30
+ */
31
+ jwtParams: Readonly<CreateJwtParams>;
32
+ } & PartialWithUndefined<{
33
+ /**
34
+ * Is set to `true` (which should only be done in development environments), the cookie will be
35
+ * allowed in insecure requests (non HTTPS requests).
36
+ *
37
+ * @default false
38
+ */
39
+ isDev: boolean;
40
+ }>;
41
+
42
+ /**
43
+ * Generate a secure cookie that stores the user JWT data. Used in host (backend) code.
44
+ *
45
+ * @category Internal
46
+ */
47
+ export async function generateCookie(
48
+ userJwtData: Readonly<UserJwtData>,
49
+ cookieConfig: Readonly<CookieParams>,
50
+ ) {
51
+ return [
52
+ `auth=${await createUserJwt(userJwtData, cookieConfig.jwtParams)}`,
53
+ `Domain=${parseUrl(cookieConfig.hostOrigin).hostname}`,
54
+ 'HttpOnly',
55
+ 'Path=/',
56
+ 'SameSite=Strict',
57
+ `MAX-AGE=${convertDuration(cookieConfig.cookieDuration, {seconds: true}).seconds}`,
58
+ cookieConfig.isDev ? '' : 'Secure',
59
+ ]
60
+ .filter(check.isTruthy)
61
+ .join('; ');
62
+ }
63
+
64
+ /**
65
+ * Extract an auth cookie from a cookie string. Used in host (backend) code.
66
+ *
67
+ * @category Internal
68
+ * @returns The extracted auth Cookie JWT data or `undefined` if no valid auth JWT data was found.
69
+ */
70
+ export async function extractCookieJwt(
71
+ rawCookie: string,
72
+ jwtParams: Readonly<ParseJwtParams>,
73
+ ): Promise<undefined | UserJwtData> {
74
+ const [auth] = safeMatch(rawCookie, /auth=[^;]+(?:;|$)/);
75
+
76
+ if (!auth) {
77
+ return undefined;
78
+ }
79
+
80
+ const rawJwt = auth.replace('auth=', '').replace(';', '');
81
+
82
+ const jwt = await parseUserJwt(rawJwt, jwtParams);
83
+
84
+ return jwt;
85
+ }
@@ -0,0 +1,17 @@
1
+ import {randomString} from '@augment-vir/common';
2
+
3
+ /**
4
+ * Name of the header attached to responses that stores the CSRF token value.
5
+ *
6
+ * @category Internal
7
+ */
8
+ export const csrfTokenHeaderName = 'csrf-token';
9
+
10
+ /**
11
+ * Generates a random, cryptographically secure CSRF token.
12
+ *
13
+ * @category Internal
14
+ */
15
+ export function generateCsrfToken(): string {
16
+ return randomString(256);
17
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,65 @@
1
+ import {bcrypt, bcryptVerify} from 'hash-wasm';
2
+
3
+ /**
4
+ * Hashes a password using the bcrypt algorithm so passwords don't need to be stored in plain text.
5
+ * The output of this function is safe to store in a database for future credential comparisons.
6
+ *
7
+ * @category Auth : Host
8
+ * @returns `undefined` if the password is too long (and would be truncated by the bcrypt hashing
9
+ * algorithm). Otherwise, the hashed output.
10
+ * @see https://wikipedia.org/wiki/Bcrypt
11
+ */
12
+ export async function hashPassword(password: string): Promise<undefined | string> {
13
+ if (willHashTruncate(password)) {
14
+ return undefined;
15
+ }
16
+
17
+ const salt = new Uint8Array(16);
18
+ globalThis.crypto.getRandomValues(salt);
19
+
20
+ return await bcrypt({
21
+ costFactor: 10,
22
+ password: password.normalize(),
23
+ salt,
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Checks if the given string will be truncated when passed through {@link hashPassword}. Passwords
29
+ * longer than this should not be accepted.
30
+ *
31
+ * @category Internal
32
+ */
33
+ export function willHashTruncate(input: string): boolean {
34
+ return getByteLength(input) > 72;
35
+ }
36
+
37
+ /**
38
+ * A utility that provides more accurate string byte size than doing `string.length`.
39
+ *
40
+ * @category Internal
41
+ */
42
+ export function getByteLength(input: string): number {
43
+ return new Blob([input]).size;
44
+ }
45
+
46
+ /**
47
+ * Checks if the given password is a match by comparing it to its previously computed and stored
48
+ * hash.
49
+ *
50
+ * @category Auth : Host
51
+ */
52
+ export async function doesPasswordMatchHash({
53
+ password,
54
+ hash,
55
+ }: {
56
+ /** The password entered by the user in their login attempt. */
57
+ password: string;
58
+ /** The stored password hash for that user. */
59
+ hash: string;
60
+ }): Promise<boolean> {
61
+ return await bcryptVerify({
62
+ hash,
63
+ password,
64
+ });
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './auth.js';
2
+ export * from './cookie.js';
3
+ export * from './csrf-token.js';
4
+ export * from './hash.js';
5
+ export * from './jwt-keys.js';
6
+ export * from './jwt.js';
7
+ export * from './mock-local-storage.js';
8
+ export * from './user-jwt.js';
@@ -0,0 +1,3 @@
1
+ import {generateNewJwtKeys} from './jwt-keys.js';
2
+
3
+ console.info(await generateNewJwtKeys());
@@ -0,0 +1,111 @@
1
+ import {assertWrap} from '@augment-vir/assert';
2
+ import {base64url} from 'jose';
3
+
4
+ /**
5
+ * The keys required to sign and encrypt the JWT in their raw form for storage in a secure secrets
6
+ * database (such as AWS Secrets Manager) for later parsing by {@link parseJwtKeys}.
7
+ *
8
+ * These keys should be kept secret and never shared with any frontend, client, etc.
9
+ *
10
+ * @category Internal
11
+ */
12
+ export type RawJwtKeys = Readonly<{
13
+ encryptionKey: string;
14
+ signingKey: string;
15
+ }>;
16
+
17
+ /**
18
+ * The keys required to sign and encrypt the JWT.
19
+ *
20
+ * These keys should be kept secret and never shared with any frontend, client, etc.
21
+ *
22
+ * @category Internal
23
+ */
24
+ export type JwtKeys = Readonly<{
25
+ /**
26
+ * Encryption key for JWTs. This is a Uint8Array because `EncryptJWT.encrypt` does not support
27
+ * `CryptoKey` for our chosen encryption algorithm.
28
+ */
29
+ encryptionKey: Readonly<Uint8Array>;
30
+ /** Signing key for JWTs. */
31
+ signingKey: Readonly<CryptoKey>;
32
+ }>;
33
+
34
+ const signingKeyOptions: [HmacKeyGenParams, boolean, ReadonlyArray<KeyUsage>] = [
35
+ {
36
+ name: 'HMAC',
37
+ hash: 'SHA-512',
38
+ },
39
+ true,
40
+ [
41
+ 'sign',
42
+ 'verify',
43
+ ],
44
+ ];
45
+
46
+ /**
47
+ * Generate fresh and serialized JWT signing and encryption keys. These should be stored in a secure
48
+ * secrets database (such as AWS Secrets Manager) for later parsing by {@link parseJwtKeys}.
49
+ *
50
+ * These keys should be kept secret and never shared with any frontend, client, etc.
51
+ *
52
+ * @category Keys
53
+ */
54
+ export async function generateNewJwtKeys(): Promise<RawJwtKeys> {
55
+ return {
56
+ encryptionKey: assertWrap.isDefined(
57
+ (
58
+ await globalThis.crypto.subtle.exportKey(
59
+ 'jwk',
60
+ await globalThis.crypto.subtle.generateKey(
61
+ {
62
+ name: 'AES-GCM',
63
+ length: 256,
64
+ },
65
+ true,
66
+ [
67
+ 'encrypt',
68
+ 'decrypt',
69
+ ],
70
+ ),
71
+ )
72
+ ).k,
73
+ ),
74
+ signingKey: assertWrap.isDefined(
75
+ (
76
+ await globalThis.crypto.subtle.exportKey(
77
+ 'jwk',
78
+ await globalThis.crypto.subtle.generateKey(...signingKeyOptions),
79
+ )
80
+ ).k,
81
+ ),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Parses an instance of {@link RawJwtKeys} and produces the final {@link JwtKeys} object required by
87
+ * all authentication functionality.
88
+ *
89
+ * @category Keys
90
+ */
91
+ export async function parseJwtKeys(rawKeys: Readonly<RawJwtKeys>): Promise<Readonly<JwtKeys>> {
92
+ if (!rawKeys.encryptionKey) {
93
+ throw new Error('JWT encryption key is empty');
94
+ } else if (!rawKeys.signingKey) {
95
+ throw new Error('JWT signing key is empty');
96
+ }
97
+ return {
98
+ encryptionKey: base64url.decode(rawKeys.encryptionKey),
99
+ signingKey: await crypto.subtle.importKey(
100
+ 'jwk',
101
+ {
102
+ k: rawKeys.signingKey,
103
+ alg: 'HS512',
104
+ ext: signingKeyOptions[1],
105
+ key_ops: [...signingKeyOptions[2]],
106
+ kty: 'oct',
107
+ },
108
+ ...signingKeyOptions,
109
+ ),
110
+ };
111
+ }