@baukit/auth-node 0.7.2 → 0.7.4
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 +2 -1
- package/src/api-origin.test.ts +72 -0
- package/src/api-origin.ts +58 -0
- package/src/cache.ts +381 -0
- package/src/conformance.test.ts +139 -0
- package/src/device-flow.ts +817 -0
- package/src/errors.ts +64 -0
- package/src/index.test.ts +578 -0
- package/src/index.ts +30 -0
- package/src/keycloak-testing.test.ts +366 -0
- package/src/keycloak-testing.ts +267 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baukit/auth-node",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Node OIDC device authorization with S256 PKCE, refresh rotation, and a locked token cache.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Patrick Koss",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"dist",
|
|
43
|
+
"src",
|
|
43
44
|
"README.md",
|
|
44
45
|
"LICENSE"
|
|
45
46
|
],
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ApiOriginError, parseApiOrigin, type ApiOriginErrorReason } from './index.js';
|
|
4
|
+
|
|
5
|
+
function reasonFor(value: string, allowLoopbackHttp = false): ApiOriginErrorReason | undefined {
|
|
6
|
+
try {
|
|
7
|
+
parseApiOrigin(value, { allowLoopbackHttp });
|
|
8
|
+
return undefined;
|
|
9
|
+
} catch (cause) {
|
|
10
|
+
if (cause instanceof ApiOriginError) return cause.reason;
|
|
11
|
+
throw cause;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('parseApiOrigin', () => {
|
|
16
|
+
it('returns the origin of an https URL', () => {
|
|
17
|
+
expect(parseApiOrigin('https://api.example.com')).toBe('https://api.example.com');
|
|
18
|
+
expect(parseApiOrigin(' https://API.example.com:8443/ \n')).toBe(
|
|
19
|
+
'https://api.example.com:8443',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('allows plain http only on loopback hosts when asked', () => {
|
|
24
|
+
for (const value of [
|
|
25
|
+
'http://localhost:8080',
|
|
26
|
+
'http://127.0.0.1:8080',
|
|
27
|
+
'http://127.10.0.2',
|
|
28
|
+
'http://[::1]:8080',
|
|
29
|
+
]) {
|
|
30
|
+
expect(parseApiOrigin(value, { allowLoopbackHttp: true })).toBe(value);
|
|
31
|
+
expect(reasonFor(value)).toBe('insecure_scheme');
|
|
32
|
+
}
|
|
33
|
+
expect(reasonFor('http://api.example.com', true)).toBe('insecure_scheme');
|
|
34
|
+
expect(reasonFor('http://localhost.example.com', true)).toBe('insecure_scheme');
|
|
35
|
+
expect(reasonFor('http://192.168.1.10', true)).toBe('insecure_scheme');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects anything beyond an origin', () => {
|
|
39
|
+
for (const value of [
|
|
40
|
+
'https://api.example.com/api',
|
|
41
|
+
'https://api.example.com//',
|
|
42
|
+
'https://api.example.com/?page=1',
|
|
43
|
+
'https://api.example.com/#top',
|
|
44
|
+
'https://user:secret@api.example.com',
|
|
45
|
+
]) {
|
|
46
|
+
expect(reasonFor(value)).toBe('not_an_origin');
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects values that are not absolute web URLs', () => {
|
|
51
|
+
expect(reasonFor('')).toBe('invalid_url');
|
|
52
|
+
expect(reasonFor('api.example.com')).toBe('invalid_url');
|
|
53
|
+
expect(reasonFor('ftp://api.example.com')).toBe('insecure_scheme');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('names the setting in the message but never the value', () => {
|
|
57
|
+
const error = (() => {
|
|
58
|
+
try {
|
|
59
|
+
parseApiOrigin('https://user:secret@api.example.com', { label: 'PRODUCT_API_URL' });
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
return cause;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
})();
|
|
65
|
+
|
|
66
|
+
expect(error).toBeInstanceOf(TypeError);
|
|
67
|
+
expect((error as Error).message).toBe(
|
|
68
|
+
'PRODUCT_API_URL must be an origin without credentials, path, query, or fragment.',
|
|
69
|
+
);
|
|
70
|
+
expect((error as Error).message).not.toContain('secret');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type ApiOriginErrorReason = 'invalid_url' | 'insecure_scheme' | 'not_an_origin';
|
|
2
|
+
|
|
3
|
+
const REASON_MESSAGES = {
|
|
4
|
+
invalid_url: 'must be an absolute URL',
|
|
5
|
+
insecure_scheme: 'must use https, or http on a loopback host when that is allowed',
|
|
6
|
+
not_an_origin: 'must be an origin without credentials, path, query, or fragment',
|
|
7
|
+
} as const satisfies Record<ApiOriginErrorReason, string>;
|
|
8
|
+
|
|
9
|
+
/** Names the setting and the rule it broke, never the configured value. */
|
|
10
|
+
export class ApiOriginError extends TypeError {
|
|
11
|
+
public readonly reason: ApiOriginErrorReason;
|
|
12
|
+
|
|
13
|
+
public constructor(label: string, reason: ApiOriginErrorReason) {
|
|
14
|
+
super(`${label} ${REASON_MESSAGES[reason]}.`);
|
|
15
|
+
this.name = 'ApiOriginError';
|
|
16
|
+
this.reason = reason;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ApiOriginOptions {
|
|
21
|
+
/** Permits plain HTTP only for localhost and literal loopback addresses. Defaults to false. */
|
|
22
|
+
readonly allowLoopbackHttp?: boolean;
|
|
23
|
+
/** Setting name used in error messages. Defaults to `API URL`. */
|
|
24
|
+
readonly label?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parses a configured API base URL and returns its origin, such as
|
|
29
|
+
* `https://api.example.com`. Surrounding whitespace and one trailing slash are
|
|
30
|
+
* accepted; anything beyond the origin is rejected.
|
|
31
|
+
*/
|
|
32
|
+
export function parseApiOrigin(value: string, options: ApiOriginOptions = {}): string {
|
|
33
|
+
const label = options.label ?? 'API URL';
|
|
34
|
+
let url: URL;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(value.trim());
|
|
37
|
+
} catch {
|
|
38
|
+
throw new ApiOriginError(label, 'invalid_url');
|
|
39
|
+
}
|
|
40
|
+
if (!isAllowedWebScheme(url, options.allowLoopbackHttp ?? false)) {
|
|
41
|
+
throw new ApiOriginError(label, 'insecure_scheme');
|
|
42
|
+
}
|
|
43
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
44
|
+
throw new ApiOriginError(label, 'not_an_origin');
|
|
45
|
+
}
|
|
46
|
+
return url.origin;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True for https, and for http on a loopback host when `allowLoopbackHttp` is set. */
|
|
50
|
+
export function isAllowedWebScheme(url: URL, allowLoopbackHttp: boolean): boolean {
|
|
51
|
+
if (url.protocol === 'https:') return true;
|
|
52
|
+
return url.protocol === 'http:' && allowLoopbackHttp && isLoopbackHost(url.hostname);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
56
|
+
const host = hostname.replace(/^\[|\]$/gu, '').toLowerCase();
|
|
57
|
+
return host === 'localhost' || host === '::1' || /^127(?:\.\d{1,3}){3}$/u.test(host);
|
|
58
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { constants, type Stats } from 'node:fs';
|
|
4
|
+
import { chmod, lstat, mkdir, open, rename, unlink, type FileHandle } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, join, parse, resolve, sep } from 'node:path';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
import { AuthNodeError } from './errors.js';
|
|
10
|
+
|
|
11
|
+
const CACHE_VERSION = 1;
|
|
12
|
+
const CACHE_MAX_BYTES = 256 * 1024;
|
|
13
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
|
|
14
|
+
const DEFAULT_LOCK_RETRY_MS = 50;
|
|
15
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
16
|
+
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
17
|
+
const UNSAFE_MODE_BITS = 0o077;
|
|
18
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
19
|
+
|
|
20
|
+
export interface CachedTokenProfile {
|
|
21
|
+
readonly accessToken: string;
|
|
22
|
+
readonly refreshToken?: string;
|
|
23
|
+
readonly idToken?: string;
|
|
24
|
+
readonly tokenType: string;
|
|
25
|
+
readonly expiresAt: number;
|
|
26
|
+
readonly issuer: string;
|
|
27
|
+
readonly clientId: string;
|
|
28
|
+
readonly scopes: readonly string[];
|
|
29
|
+
readonly audience?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CacheDocument {
|
|
33
|
+
readonly version: typeof CACHE_VERSION;
|
|
34
|
+
readonly namespace: string;
|
|
35
|
+
readonly profiles: Readonly<Record<string, CachedTokenProfile>>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TokenCacheTransaction {
|
|
39
|
+
read(profile: string): Promise<CachedTokenProfile | undefined>;
|
|
40
|
+
write(profile: string, value: CachedTokenProfile): Promise<void>;
|
|
41
|
+
remove(profile: string): Promise<boolean>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface NodeTokenCacheOptions {
|
|
45
|
+
readonly namespace: string;
|
|
46
|
+
readonly path?: string;
|
|
47
|
+
readonly platform?: NodeJS.Platform;
|
|
48
|
+
readonly now?: () => number;
|
|
49
|
+
readonly sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
50
|
+
readonly lockTimeoutMs?: number;
|
|
51
|
+
readonly lockRetryMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function defaultTokenCachePath(
|
|
55
|
+
namespace: string,
|
|
56
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
57
|
+
): string {
|
|
58
|
+
validateName(namespace, 'cache namespace');
|
|
59
|
+
const configuredHome = environment['XDG_CONFIG_HOME']?.trim();
|
|
60
|
+
const configHome =
|
|
61
|
+
configuredHome === undefined || configuredHome.length === 0
|
|
62
|
+
? join(homedir(), '.config')
|
|
63
|
+
: configuredHome;
|
|
64
|
+
return join(configHome, namespace, 'tokens.json');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A versioned profile cache that serializes mutations with an adjacent lock file. */
|
|
68
|
+
export class NodeTokenCache {
|
|
69
|
+
public readonly path: string;
|
|
70
|
+
public readonly namespace: string;
|
|
71
|
+
readonly #platform: NodeJS.Platform;
|
|
72
|
+
readonly #now: () => number;
|
|
73
|
+
readonly #sleep: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
74
|
+
readonly #lockTimeoutMs: number;
|
|
75
|
+
readonly #lockRetryMs: number;
|
|
76
|
+
|
|
77
|
+
public constructor(options: NodeTokenCacheOptions) {
|
|
78
|
+
validateName(options.namespace, 'cache namespace');
|
|
79
|
+
this.namespace = options.namespace;
|
|
80
|
+
this.path = resolve(options.path ?? defaultTokenCachePath(options.namespace));
|
|
81
|
+
this.#platform = options.platform ?? process.platform;
|
|
82
|
+
this.#now = options.now ?? Date.now;
|
|
83
|
+
this.#sleep = options.sleep ?? abortableDelay;
|
|
84
|
+
this.#lockTimeoutMs = positiveInteger(
|
|
85
|
+
options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
|
|
86
|
+
'cache lock timeout',
|
|
87
|
+
);
|
|
88
|
+
this.#lockRetryMs = positiveInteger(
|
|
89
|
+
options.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS,
|
|
90
|
+
'cache lock retry interval',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public async read(profile = 'default'): Promise<CachedTokenProfile | undefined> {
|
|
95
|
+
validateName(profile, 'cache profile');
|
|
96
|
+
const document = await this.#readDocument();
|
|
97
|
+
return document === undefined ? undefined : ownProfile(document.profiles, profile);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public write(profile: string, value: CachedTokenProfile, signal?: AbortSignal): Promise<void> {
|
|
101
|
+
return this.withLock(async (transaction) => {
|
|
102
|
+
await transaction.write(profile, value);
|
|
103
|
+
}, signal);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public remove(profile = 'default', signal?: AbortSignal): Promise<boolean> {
|
|
107
|
+
return this.withLock((transaction) => transaction.remove(profile), signal);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public async withLock<T>(
|
|
111
|
+
action: (transaction: TokenCacheTransaction) => Promise<T>,
|
|
112
|
+
signal?: AbortSignal,
|
|
113
|
+
): Promise<T> {
|
|
114
|
+
throwIfAborted(signal);
|
|
115
|
+
const lock = await this.#acquireLock(signal);
|
|
116
|
+
try {
|
|
117
|
+
const transaction: TokenCacheTransaction = {
|
|
118
|
+
read: (profile) => this.#readProfile(profile),
|
|
119
|
+
write: (profile, value) => this.#writeProfile(profile, value),
|
|
120
|
+
remove: (profile) => this.#removeProfile(profile),
|
|
121
|
+
};
|
|
122
|
+
return await action(transaction);
|
|
123
|
+
} finally {
|
|
124
|
+
await this.#releaseLock(lock);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #readProfile(profile: string): Promise<CachedTokenProfile | undefined> {
|
|
129
|
+
validateName(profile, 'cache profile');
|
|
130
|
+
const document = await this.#readDocument();
|
|
131
|
+
return document === undefined ? undefined : ownProfile(document.profiles, profile);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async #writeProfile(profile: string, value: CachedTokenProfile): Promise<void> {
|
|
135
|
+
validateName(profile, 'cache profile');
|
|
136
|
+
validateProfile(value);
|
|
137
|
+
const current = await this.#readDocument();
|
|
138
|
+
const profiles = { ...(current?.profiles ?? {}), [profile]: value };
|
|
139
|
+
await this.#replaceDocument({ version: CACHE_VERSION, namespace: this.namespace, profiles });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #removeProfile(profile: string): Promise<boolean> {
|
|
143
|
+
validateName(profile, 'cache profile');
|
|
144
|
+
const current = await this.#readDocument();
|
|
145
|
+
if (current === undefined || ownProfile(current.profiles, profile) === undefined) return false;
|
|
146
|
+
const profiles = Object.fromEntries(
|
|
147
|
+
Object.entries(current.profiles).filter(([name]) => name !== profile),
|
|
148
|
+
);
|
|
149
|
+
await this.#replaceDocument({ version: CACHE_VERSION, namespace: this.namespace, profiles });
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async #readDocument(): Promise<CacheDocument | undefined> {
|
|
154
|
+
await assertNoSymlink(this.path, true);
|
|
155
|
+
let handle: FileHandle;
|
|
156
|
+
try {
|
|
157
|
+
handle = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (nodeError(error, 'ENOENT')) return undefined;
|
|
160
|
+
throw cacheIoError(error, 'cache_read_failed');
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const metadata = await handle.stat();
|
|
164
|
+
validateCacheFile(metadata, this.#platform);
|
|
165
|
+
if (metadata.size > CACHE_MAX_BYTES) throw new AuthNodeError('cache_corrupt');
|
|
166
|
+
const contents = await handle.readFile({ encoding: 'utf8' });
|
|
167
|
+
if (Buffer.byteLength(contents) > CACHE_MAX_BYTES) {
|
|
168
|
+
throw new AuthNodeError('cache_corrupt');
|
|
169
|
+
}
|
|
170
|
+
return parseDocument(contents, this.namespace);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof AuthNodeError) throw error;
|
|
173
|
+
throw cacheIoError(error, 'cache_read_failed');
|
|
174
|
+
} finally {
|
|
175
|
+
await handle.close().catch(() => undefined);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async #replaceDocument(document: CacheDocument): Promise<void> {
|
|
180
|
+
const directory = dirname(this.path);
|
|
181
|
+
await preparePrivateDirectory(directory, this.#platform);
|
|
182
|
+
await assertNoSymlink(this.path, true);
|
|
183
|
+
const temporaryPath = `${this.path}.${String(process.pid)}.${randomUUID()}.tmp`;
|
|
184
|
+
let handle: FileHandle | undefined;
|
|
185
|
+
try {
|
|
186
|
+
handle = await open(temporaryPath, 'wx', PRIVATE_FILE_MODE);
|
|
187
|
+
await handle.writeFile(`${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
|
188
|
+
await handle.sync();
|
|
189
|
+
await handle.close();
|
|
190
|
+
handle = undefined;
|
|
191
|
+
if (this.#platform !== 'win32') await chmod(temporaryPath, PRIVATE_FILE_MODE);
|
|
192
|
+
await assertNoSymlink(this.path, true);
|
|
193
|
+
await rename(temporaryPath, this.path);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
await handle?.close().catch(() => undefined);
|
|
196
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
197
|
+
if (error instanceof AuthNodeError) throw error;
|
|
198
|
+
throw cacheIoError(error, 'cache_write_failed');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async #acquireLock(signal?: AbortSignal): Promise<FileHandle> {
|
|
203
|
+
const directory = dirname(this.path);
|
|
204
|
+
await preparePrivateDirectory(directory, this.#platform);
|
|
205
|
+
const lockPath = `${this.path}.lock`;
|
|
206
|
+
const deadline = this.#now() + this.#lockTimeoutMs;
|
|
207
|
+
for (;;) {
|
|
208
|
+
throwIfAborted(signal);
|
|
209
|
+
await assertNoSymlink(lockPath, true);
|
|
210
|
+
try {
|
|
211
|
+
return await open(lockPath, 'wx', PRIVATE_FILE_MODE);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!nodeError(error, 'EEXIST')) throw cacheIoError(error, 'cache_write_failed');
|
|
214
|
+
}
|
|
215
|
+
if (this.#now() >= deadline)
|
|
216
|
+
throw new AuthNodeError('cache_lock_timeout', { retryable: true });
|
|
217
|
+
await this.#sleep(this.#lockRetryMs, signal);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async #releaseLock(handle: FileHandle): Promise<void> {
|
|
222
|
+
const lockPath = `${this.path}.lock`;
|
|
223
|
+
let stillOwned: boolean;
|
|
224
|
+
try {
|
|
225
|
+
const held = await handle.stat();
|
|
226
|
+
const current = await lstat(lockPath);
|
|
227
|
+
stillOwned = held.dev === current.dev && held.ino === current.ino;
|
|
228
|
+
} catch {
|
|
229
|
+
stillOwned = false;
|
|
230
|
+
}
|
|
231
|
+
await handle.close().catch(() => undefined);
|
|
232
|
+
if (stillOwned) await unlink(lockPath).catch(() => undefined);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseDocument(contents: string, namespace: string): CacheDocument {
|
|
237
|
+
let value: unknown;
|
|
238
|
+
try {
|
|
239
|
+
value = JSON.parse(contents) as unknown;
|
|
240
|
+
} catch {
|
|
241
|
+
throw new AuthNodeError('cache_corrupt');
|
|
242
|
+
}
|
|
243
|
+
if (!isRecord(value) || value['version'] !== CACHE_VERSION || value['namespace'] !== namespace) {
|
|
244
|
+
throw new AuthNodeError('cache_corrupt');
|
|
245
|
+
}
|
|
246
|
+
const rawProfiles = value['profiles'];
|
|
247
|
+
if (!isRecord(rawProfiles)) throw new AuthNodeError('cache_corrupt');
|
|
248
|
+
const profiles: Record<string, CachedTokenProfile> = {};
|
|
249
|
+
for (const [name, profile] of Object.entries(rawProfiles)) {
|
|
250
|
+
validateNameOrCorrupt(name);
|
|
251
|
+
validateProfile(profile);
|
|
252
|
+
profiles[name] = profile;
|
|
253
|
+
}
|
|
254
|
+
return { version: CACHE_VERSION, namespace, profiles };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function validateProfile(value: unknown): asserts value is CachedTokenProfile {
|
|
258
|
+
if (
|
|
259
|
+
!isRecord(value) ||
|
|
260
|
+
!nonEmptyString(value['accessToken']) ||
|
|
261
|
+
(value['refreshToken'] !== undefined && !nonEmptyString(value['refreshToken'])) ||
|
|
262
|
+
(value['idToken'] !== undefined && !nonEmptyString(value['idToken'])) ||
|
|
263
|
+
!nonEmptyString(value['tokenType']) ||
|
|
264
|
+
typeof value['expiresAt'] !== 'number' ||
|
|
265
|
+
!Number.isFinite(value['expiresAt']) ||
|
|
266
|
+
value['expiresAt'] <= 0 ||
|
|
267
|
+
!nonEmptyString(value['issuer']) ||
|
|
268
|
+
!nonEmptyString(value['clientId']) ||
|
|
269
|
+
!Array.isArray(value['scopes']) ||
|
|
270
|
+
!value['scopes'].every(nonEmptyString) ||
|
|
271
|
+
(value['audience'] !== undefined && !nonEmptyString(value['audience']))
|
|
272
|
+
) {
|
|
273
|
+
throw new AuthNodeError('cache_corrupt');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function preparePrivateDirectory(path: string, platform: NodeJS.Platform): Promise<void> {
|
|
278
|
+
try {
|
|
279
|
+
await assertNoSymlink(path, false);
|
|
280
|
+
await mkdir(path, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
|
281
|
+
await assertNoSymlink(path, false);
|
|
282
|
+
const metadata = await lstat(path);
|
|
283
|
+
if (!metadata.isDirectory()) throw new AuthNodeError('cache_permission');
|
|
284
|
+
if (platform !== 'win32' && (metadata.mode & UNSAFE_MODE_BITS) !== 0) {
|
|
285
|
+
throw new AuthNodeError('cache_permission');
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error instanceof AuthNodeError) throw error;
|
|
289
|
+
throw cacheIoError(error, 'cache_permission');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function assertNoSymlink(path: string, allowMissingLeaf: boolean): Promise<void> {
|
|
294
|
+
const absolute = resolve(path);
|
|
295
|
+
const root = parse(absolute).root;
|
|
296
|
+
const parts = absolute.slice(root.length).split(sep).filter(Boolean);
|
|
297
|
+
let current = root;
|
|
298
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
299
|
+
current = join(current, parts[index] ?? '');
|
|
300
|
+
try {
|
|
301
|
+
const metadata = await lstat(current);
|
|
302
|
+
if (metadata.isSymbolicLink()) throw new AuthNodeError('cache_symlink');
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (nodeError(error, 'ENOENT') && (allowMissingLeaf || index < parts.length)) return;
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function validateCacheFile(metadata: Stats, platform: NodeJS.Platform): void {
|
|
311
|
+
if (!metadata.isFile()) throw new AuthNodeError('cache_permission');
|
|
312
|
+
if (platform !== 'win32' && (metadata.mode & UNSAFE_MODE_BITS) !== 0) {
|
|
313
|
+
throw new AuthNodeError('cache_permission');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function validateName(value: string, label: string): void {
|
|
318
|
+
if (!SAFE_NAME.test(value)) throw new TypeError(`${label} has an invalid format.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateNameOrCorrupt(value: string): void {
|
|
322
|
+
if (!SAFE_NAME.test(value)) throw new AuthNodeError('cache_corrupt');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function positiveInteger(value: number, label: string): number {
|
|
326
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
327
|
+
throw new RangeError(`${label} must be positive.`);
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function cacheIoError(
|
|
332
|
+
error: unknown,
|
|
333
|
+
fallback: 'cache_read_failed' | 'cache_write_failed' | 'cache_permission',
|
|
334
|
+
): AuthNodeError {
|
|
335
|
+
return new AuthNodeError(
|
|
336
|
+
nodeError(error, 'EACCES') || nodeError(error, 'EPERM') ? 'cache_permission' : fallback,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function nodeError(error: unknown, code: string): boolean {
|
|
341
|
+
return (
|
|
342
|
+
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
347
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function nonEmptyString(value: unknown): value is string {
|
|
351
|
+
return typeof value === 'string' && value.length > 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function ownProfile(
|
|
355
|
+
profiles: Readonly<Record<string, CachedTokenProfile>>,
|
|
356
|
+
name: string,
|
|
357
|
+
): CachedTokenProfile | undefined {
|
|
358
|
+
return Object.hasOwn(profiles, name) ? profiles[name] : undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
362
|
+
if (signal?.aborted === true) throw new AuthNodeError('aborted');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
|
366
|
+
return new Promise((resolveDelay, reject) => {
|
|
367
|
+
if (signal?.aborted === true) {
|
|
368
|
+
reject(new AuthNodeError('aborted'));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const onAbort = (): void => {
|
|
372
|
+
clearTimeout(timeout);
|
|
373
|
+
reject(new AuthNodeError('aborted'));
|
|
374
|
+
};
|
|
375
|
+
const timeout = setTimeout(() => {
|
|
376
|
+
signal?.removeEventListener('abort', onAbort);
|
|
377
|
+
resolveDelay();
|
|
378
|
+
}, milliseconds);
|
|
379
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
380
|
+
});
|
|
381
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { DeviceFlowClient } from './index.js';
|
|
11
|
+
|
|
12
|
+
describe('RFC 8628 in-process issuer conformance', () => {
|
|
13
|
+
let issuer = '';
|
|
14
|
+
let pollCount = 0;
|
|
15
|
+
let refreshCount = 0;
|
|
16
|
+
let observedChallenge = '';
|
|
17
|
+
let observedVerifier = '';
|
|
18
|
+
let temporaryDirectory = '';
|
|
19
|
+
const server = createServer((request, response) => {
|
|
20
|
+
void route(request, response);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
await new Promise<void>((resolve, reject) => {
|
|
25
|
+
server.once('error', reject);
|
|
26
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
27
|
+
});
|
|
28
|
+
const address = server.address();
|
|
29
|
+
if (address === null || typeof address === 'string') throw new Error('Missing server address.');
|
|
30
|
+
issuer = `http://127.0.0.1:${String(address.port)}/issuer`;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
await new Promise<void>((resolve, reject) => {
|
|
35
|
+
server.close((error) => {
|
|
36
|
+
if (error === undefined) resolve();
|
|
37
|
+
else reject(error);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
if (temporaryDirectory.length > 0) {
|
|
41
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('completes discovery, device authorization, pending polling, PKCE, and refresh', async () => {
|
|
46
|
+
temporaryDirectory = await mkdtemp(join(tmpdir(), 'baukit-auth-node-conformance-'));
|
|
47
|
+
const cachePath = join(temporaryDirectory, 'tokens.json');
|
|
48
|
+
const auth = new DeviceFlowClient(
|
|
49
|
+
{
|
|
50
|
+
issuer,
|
|
51
|
+
clientId: 'conformance-client',
|
|
52
|
+
cache: { namespace: 'baukit-auth-node-conformance', path: cachePath },
|
|
53
|
+
endpointPolicy: { allowLoopbackHttp: true },
|
|
54
|
+
refreshLeewaySeconds: 0,
|
|
55
|
+
},
|
|
56
|
+
{ sleep: () => Promise.resolve(), now: () => 1_000 },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const loggedIn = await auth.login();
|
|
60
|
+
expect(loggedIn).toMatchObject({
|
|
61
|
+
accessToken: 'conformance-access',
|
|
62
|
+
refreshToken: 'conformance-refresh',
|
|
63
|
+
});
|
|
64
|
+
expect(pollCount).toBe(2);
|
|
65
|
+
expect(observedChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/u);
|
|
66
|
+
expect(observedVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/u);
|
|
67
|
+
expect(observedVerifier).not.toBe(observedChallenge);
|
|
68
|
+
await expect(auth.accessToken({ forceRefresh: true })).resolves.toBe('conformance-refreshed');
|
|
69
|
+
expect(refreshCount).toBe(1);
|
|
70
|
+
await auth.logout();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function route(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
74
|
+
if (request.url === '/issuer/.well-known/openid-configuration') {
|
|
75
|
+
send(response, 200, {
|
|
76
|
+
issuer,
|
|
77
|
+
device_authorization_endpoint: `${issuer}/device`,
|
|
78
|
+
token_endpoint: `${issuer}/token`,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const body = new URLSearchParams(await readBody(request));
|
|
83
|
+
if (request.url === '/issuer/device') {
|
|
84
|
+
expect(body.get('client_id')).toBe('conformance-client');
|
|
85
|
+
expect(body.get('code_challenge_method')).toBe('S256');
|
|
86
|
+
observedChallenge = body.get('code_challenge') ?? '';
|
|
87
|
+
send(response, 200, {
|
|
88
|
+
device_code: 'conformance-device',
|
|
89
|
+
user_code: 'TEST-CODE',
|
|
90
|
+
verification_uri: `${issuer}/verify`,
|
|
91
|
+
expires_in: 60,
|
|
92
|
+
interval: 1,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (request.url === '/issuer/token') {
|
|
97
|
+
if (body.get('grant_type') === 'refresh_token') {
|
|
98
|
+
expect(body.get('refresh_token')).toBe('conformance-refresh');
|
|
99
|
+
refreshCount += 1;
|
|
100
|
+
send(response, 200, {
|
|
101
|
+
access_token: 'conformance-refreshed',
|
|
102
|
+
token_type: 'Bearer',
|
|
103
|
+
expires_in: 300,
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
observedVerifier = body.get('code_verifier') ?? '';
|
|
108
|
+
pollCount += 1;
|
|
109
|
+
if (pollCount === 1) {
|
|
110
|
+
send(response, 400, { error: 'authorization_pending' });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
send(response, 200, {
|
|
114
|
+
access_token: 'conformance-access',
|
|
115
|
+
refresh_token: 'conformance-refresh',
|
|
116
|
+
token_type: 'Bearer',
|
|
117
|
+
expires_in: 300,
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
send(response, 404, { error: 'not_found' });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function readBody(request: IncomingMessage): Promise<string> {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const chunks: Buffer[] = [];
|
|
128
|
+
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
129
|
+
request.on('end', () => {
|
|
130
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
131
|
+
});
|
|
132
|
+
request.on('error', reject);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function send(response: ServerResponse, status: number, body: unknown): void {
|
|
137
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
138
|
+
response.end(JSON.stringify(body));
|
|
139
|
+
}
|