@oneunit/auth 2.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.
Files changed (75) hide show
  1. package/ARCHITECTURE.md +465 -0
  2. package/CHANGELOG.md +214 -0
  3. package/LICENSE +21 -0
  4. package/README.md +647 -0
  5. package/dist/adapters.d.ts +51 -0
  6. package/dist/adapters.d.ts.map +1 -0
  7. package/dist/adapters.js +301 -0
  8. package/dist/adapters.js.map +1 -0
  9. package/dist/auth.d.ts +59 -0
  10. package/dist/auth.d.ts.map +1 -0
  11. package/dist/auth.js +560 -0
  12. package/dist/auth.js.map +1 -0
  13. package/dist/errors.d.ts +39 -0
  14. package/dist/errors.d.ts.map +1 -0
  15. package/dist/errors.js +65 -0
  16. package/dist/errors.js.map +1 -0
  17. package/dist/index.d.ts +11 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +10 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/jwt.d.ts +11 -0
  22. package/dist/jwt.d.ts.map +1 -0
  23. package/dist/jwt.js +125 -0
  24. package/dist/jwt.js.map +1 -0
  25. package/dist/oauth.d.ts +31 -0
  26. package/dist/oauth.d.ts.map +1 -0
  27. package/dist/oauth.js +178 -0
  28. package/dist/oauth.js.map +1 -0
  29. package/dist/password.d.ts +5 -0
  30. package/dist/password.d.ts.map +1 -0
  31. package/dist/password.js +90 -0
  32. package/dist/password.js.map +1 -0
  33. package/dist/providers.d.ts +37 -0
  34. package/dist/providers.d.ts.map +1 -0
  35. package/dist/providers.js +471 -0
  36. package/dist/providers.js.map +1 -0
  37. package/dist/rbac.d.ts +40 -0
  38. package/dist/rbac.d.ts.map +1 -0
  39. package/dist/rbac.js +240 -0
  40. package/dist/rbac.js.map +1 -0
  41. package/dist/roles.d.ts +2 -0
  42. package/dist/roles.d.ts.map +1 -0
  43. package/dist/roles.js +2 -0
  44. package/dist/roles.js.map +1 -0
  45. package/dist/token.d.ts +2 -0
  46. package/dist/token.d.ts.map +1 -0
  47. package/dist/token.js +2 -0
  48. package/dist/token.js.map +1 -0
  49. package/dist/types.d.ts +383 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +2 -0
  52. package/dist/types.js.map +1 -0
  53. package/dist/utils.d.ts +35 -0
  54. package/dist/utils.d.ts.map +1 -0
  55. package/dist/utils.js +192 -0
  56. package/dist/utils.js.map +1 -0
  57. package/examples/express.ts +111 -0
  58. package/examples/fastify.ts +59 -0
  59. package/examples/oauth-social.ts +83 -0
  60. package/examples/standalone.ts +67 -0
  61. package/examples/uwebsockets.ts +143 -0
  62. package/package.json +86 -0
  63. package/src/adapters.ts +333 -0
  64. package/src/auth.ts +684 -0
  65. package/src/errors.ts +76 -0
  66. package/src/index.ts +124 -0
  67. package/src/jwt.ts +159 -0
  68. package/src/oauth.ts +226 -0
  69. package/src/password.ts +111 -0
  70. package/src/providers.ts +551 -0
  71. package/src/rbac.ts +285 -0
  72. package/src/roles.ts +1 -0
  73. package/src/token.ts +1 -0
  74. package/src/types.ts +432 -0
  75. package/src/utils.ts +231 -0
package/src/errors.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { AuthErrorOptions } from "./types.js";
2
+
3
+ export class AuthError extends Error {
4
+ override readonly name: string;
5
+ readonly code: string;
6
+ readonly status: number;
7
+ override readonly cause?: unknown;
8
+
9
+ constructor(message: string, { code = "AUTH_ERROR", status = 401, cause }: AuthErrorOptions = {}) {
10
+ super(message);
11
+ this.name = this.constructor.name;
12
+ this.code = code;
13
+ this.status = status;
14
+ if (cause !== undefined) {
15
+ this.cause = cause;
16
+ }
17
+ Error.captureStackTrace?.(this, this.constructor);
18
+ }
19
+
20
+ toJSON(): { name: string; message: string; code: string; status: number } {
21
+ return {
22
+ name: this.name,
23
+ message: this.message,
24
+ code: this.code,
25
+ status: this.status,
26
+ };
27
+ }
28
+ }
29
+
30
+ export class InvalidTokenError extends AuthError {
31
+ constructor(message = "Invalid or expired token", options: AuthErrorOptions = {}) {
32
+ super(message, { code: "INVALID_TOKEN", status: 401, ...options });
33
+ }
34
+ }
35
+
36
+ export class TokenExpiredError extends AuthError {
37
+ constructor(message = "Token has expired", options: AuthErrorOptions = {}) {
38
+ super(message, { code: "TOKEN_EXPIRED", status: 401, ...options });
39
+ }
40
+ }
41
+
42
+ export class UnauthorizedError extends AuthError {
43
+ constructor(message = "Unauthorized", options: AuthErrorOptions = {}) {
44
+ super(message, { code: "UNAUTHORIZED", status: 401, ...options });
45
+ }
46
+ }
47
+
48
+ export class ForbiddenError extends AuthError {
49
+ constructor(message = "Forbidden", options: AuthErrorOptions = {}) {
50
+ super(message, { code: "FORBIDDEN", status: 403, ...options });
51
+ }
52
+ }
53
+
54
+ export class ConfigurationError extends AuthError {
55
+ constructor(message = "Invalid authentication configuration", options: AuthErrorOptions = {}) {
56
+ super(message, { code: "CONFIGURATION_ERROR", status: 500, ...options });
57
+ }
58
+ }
59
+
60
+ export class OAuthError extends AuthError {
61
+ constructor(message = "OAuth authentication failed", options: AuthErrorOptions = {}) {
62
+ super(message, { code: "OAUTH_ERROR", status: 401, ...options });
63
+ }
64
+ }
65
+
66
+ export class ProviderError extends AuthError {
67
+ constructor(message = "Identity provider error", options: AuthErrorOptions = {}) {
68
+ super(message, { code: "PROVIDER_ERROR", status: 502, ...options });
69
+ }
70
+ }
71
+
72
+ export class ValidationError extends AuthError {
73
+ constructor(message = "Validation failed", options: AuthErrorOptions = {}) {
74
+ super(message, { code: "VALIDATION_ERROR", status: 400, ...options });
75
+ }
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,124 @@
1
+ export {
2
+ AuthError,
3
+ InvalidTokenError,
4
+ TokenExpiredError,
5
+ UnauthorizedError,
6
+ ForbiddenError,
7
+ ConfigurationError,
8
+ OAuthError,
9
+ ProviderError,
10
+ ValidationError,
11
+ } from "./errors.js";
12
+
13
+ export {
14
+ encode,
15
+ decode,
16
+ decodeUnsafe,
17
+ encodeAccessToken,
18
+ encodeRefreshToken,
19
+ } from "./jwt.js";
20
+
21
+ export {
22
+ RBAC,
23
+ createRBAC,
24
+ defineRoles,
25
+ matchPermission,
26
+ } from "./rbac.js";
27
+
28
+ export {
29
+ hashPassword,
30
+ verifyPassword,
31
+ needsRehash,
32
+ } from "./password.js";
33
+
34
+ export {
35
+ OAuth,
36
+ createOAuth,
37
+ createMemoryStateStore,
38
+ } from "./oauth.js";
39
+
40
+ export {
41
+ createProvider,
42
+ getProvider,
43
+ builtinProviders,
44
+ pkceVerifier,
45
+ pkceChallenge,
46
+ google,
47
+ github,
48
+ instagram,
49
+ facebook,
50
+ twitter,
51
+ discord,
52
+ apple,
53
+ linkedin,
54
+ microsoft,
55
+ reddit,
56
+ twitch,
57
+ slack,
58
+ spotify,
59
+ tiktok,
60
+ } from "./providers.js";
61
+
62
+ export {
63
+ Auth,
64
+ auth,
65
+ createAuth,
66
+ createMemoryRefreshStore,
67
+ } from "./auth.js";
68
+
69
+ export {
70
+ expressAdapter,
71
+ fastifyAdapter,
72
+ koaAdapter,
73
+ uwsAdapter,
74
+ snapshotUwsRequest,
75
+ createAdapters,
76
+ } from "./adapters.js";
77
+
78
+ export {
79
+ parseExpiresIn,
80
+ isValidExpiresIn,
81
+ randomToken,
82
+ randomState,
83
+ extractBearerToken,
84
+ } from "./utils.js";
85
+
86
+ export type {
87
+ AuthErrorOptions,
88
+ JwtSignOptions,
89
+ JwtVerifyOptions,
90
+ JwtPayload,
91
+ Secret,
92
+ RoleDefinition,
93
+ RBACOptions,
94
+ AuthSubject,
95
+ PasswordOptions,
96
+ OAuthProfile,
97
+ OAuthTokens,
98
+ OAuthProviderConfig,
99
+ OAuthProvider,
100
+ ProviderDefinition,
101
+ StateStore,
102
+ OAuthOptions,
103
+ OAuthAuthorizeOptions,
104
+ UserRecord,
105
+ UserStore,
106
+ RefreshRecord,
107
+ RefreshStore,
108
+ LoginOptions,
109
+ LoginResult,
110
+ AuthOptions,
111
+ RequestLike,
112
+ ExtractTokenOptions,
113
+ UwsHttpResponse,
114
+ UwsHttpRequest,
115
+ UwsRequestSnapshot,
116
+ UwsHandler,
117
+ ExpressRequestLike,
118
+ ExpressResponseLike,
119
+ ExpressNext,
120
+ KoaContextLike,
121
+ FastifyLike,
122
+ FastifyRequestLike,
123
+ FastifyReplyLike,
124
+ } from "./types.js";
package/src/jwt.ts ADDED
@@ -0,0 +1,159 @@
1
+ import jwt from "jsonwebtoken";
2
+ import type { Algorithm, JwtHeader, SignOptions, VerifyOptions } from "jsonwebtoken";
3
+ import { AuthError, InvalidTokenError, TokenExpiredError, ValidationError } from "./errors.js";
4
+ import type { JwtPayload, JwtSignOptions, JwtVerifyOptions, Secret } from "./types.js";
5
+ import { isValidExpiresIn, parseExpiresIn } from "./utils.js";
6
+
7
+ const DEFAULT_ALGORITHM: Algorithm = "HS256";
8
+ const DEFAULT_EXPIRES_IN = 60 * 60 * 24;
9
+
10
+ function normalizeSecret(secret: Secret | undefined): Secret {
11
+ if (typeof secret === "string" && secret.length > 0) {
12
+ return secret;
13
+ }
14
+ if (Buffer.isBuffer(secret) && secret.length > 0) {
15
+ return secret;
16
+ }
17
+ throw new ValidationError("A non-empty secret is required to sign or verify tokens");
18
+ }
19
+
20
+ function signOptions({
21
+ expiresIn,
22
+ subject,
23
+ audience,
24
+ issuer,
25
+ jwtid,
26
+ notBefore,
27
+ header,
28
+ algorithm = DEFAULT_ALGORITHM,
29
+ keyid,
30
+ }: JwtSignOptions = {}): SignOptions {
31
+ const options: SignOptions = { algorithm: algorithm as Algorithm };
32
+
33
+ if (expiresIn !== undefined && expiresIn !== null) {
34
+ // Resolve the TTL here so an unparsable value fails loudly instead of being
35
+ // silently defaulted by parseExpiresIn while jsonwebtoken rejects it later.
36
+ if (!isValidExpiresIn(expiresIn)) {
37
+ throw new ValidationError(
38
+ `Invalid expiresIn: ${JSON.stringify(expiresIn)}. Use seconds or a timespan like "15m", "7d".`,
39
+ );
40
+ }
41
+ options.expiresIn = (typeof expiresIn === "number"
42
+ ? parseExpiresIn(expiresIn)
43
+ : expiresIn) as SignOptions["expiresIn"];
44
+ }
45
+
46
+ if (subject !== undefined) options.subject = String(subject);
47
+ if (audience !== undefined) options.audience = audience;
48
+ if (issuer !== undefined) options.issuer = issuer;
49
+ if (jwtid !== undefined) options.jwtid = jwtid;
50
+ if (notBefore !== undefined) options.notBefore = notBefore as SignOptions["notBefore"];
51
+ if (keyid !== undefined) options.keyid = keyid;
52
+ if (header !== undefined) options.header = header as unknown as JwtHeader;
53
+
54
+ return options;
55
+ }
56
+
57
+ export function encode(payload: object = {}, secret: Secret, options: JwtSignOptions = {}): string {
58
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
59
+ throw new ValidationError("Token payload must be an object");
60
+ }
61
+
62
+ const {
63
+ expiresInSeconds,
64
+ expiresIn = expiresInSeconds ?? DEFAULT_EXPIRES_IN,
65
+ subject,
66
+ ...rest
67
+ } = options;
68
+
69
+ try {
70
+ const signPayload = { ...(payload as Record<string, unknown>) };
71
+ if (subject !== undefined && signPayload.sub === undefined) {
72
+ signPayload.sub = String(subject);
73
+ }
74
+ return jwt.sign(signPayload, normalizeSecret(secret), signOptions({ expiresIn, ...rest }));
75
+ } catch (error) {
76
+ if (error instanceof AuthError) {
77
+ throw error;
78
+ }
79
+ throw new InvalidTokenError(error instanceof Error ? error.message : "Failed to sign token", { cause: error });
80
+ }
81
+ }
82
+
83
+ export function decode(token: string, secret: Secret, options: JwtVerifyOptions = {}): JwtPayload {
84
+ if (!token || typeof token !== "string") {
85
+ throw new InvalidTokenError("Token is required");
86
+ }
87
+
88
+ const {
89
+ algorithms = [DEFAULT_ALGORITHM],
90
+ audience,
91
+ issuer,
92
+ subject,
93
+ clockTolerance,
94
+ ignoreExpiration = false,
95
+ complete = false,
96
+ } = options;
97
+
98
+ try {
99
+ const verifyOptions: VerifyOptions = {
100
+ algorithms: algorithms as Algorithm[],
101
+ audience: audience as VerifyOptions["audience"],
102
+ issuer,
103
+ subject,
104
+ clockTolerance,
105
+ ignoreExpiration,
106
+ complete,
107
+ };
108
+ return jwt.verify(token, normalizeSecret(secret), verifyOptions) as JwtPayload;
109
+ } catch (error) {
110
+ if (error instanceof AuthError) {
111
+ throw error;
112
+ }
113
+ if (error instanceof Error && error.name === "TokenExpiredError") {
114
+ throw new TokenExpiredError("Token has expired", { cause: error });
115
+ }
116
+ throw new InvalidTokenError("Invalid or expired token", { cause: error });
117
+ }
118
+ }
119
+
120
+ export function decodeUnsafe(token: string, options: { complete?: boolean } = {}): unknown {
121
+ if (!token || typeof token !== "string") {
122
+ return null;
123
+ }
124
+ return jwt.decode(token, { complete: options.complete === true }) ?? null;
125
+ }
126
+
127
+ function withTyp(payload: object, typ: string, options: JwtSignOptions & { secret: Secret } = { secret: "" }): string {
128
+ const next: Record<string, unknown> = { ...(payload as Record<string, unknown>), typ };
129
+ const subject = options.subject ?? next.sub ?? next.userId;
130
+ if (subject !== undefined && next.sub === undefined) {
131
+ next.sub = String(subject);
132
+ }
133
+ return encode(next, options.secret, {
134
+ expiresIn: options.expiresIn,
135
+ audience: options.audience,
136
+ issuer: options.issuer,
137
+ jwtid: options.jwtid,
138
+ algorithm: options.algorithm,
139
+ header: options.header,
140
+ });
141
+ }
142
+
143
+ export function encodeAccessToken(payload: object, secret: Secret, options: JwtSignOptions = {}): string {
144
+ return withTyp(payload, "access", {
145
+ ...options,
146
+ secret,
147
+ expiresIn: options.expiresIn ?? "15m",
148
+ });
149
+ }
150
+
151
+ export function encodeRefreshToken(payload: object, secret: Secret, options: JwtSignOptions = {}): string {
152
+ return withTyp(payload, "refresh", {
153
+ ...options,
154
+ secret,
155
+ expiresIn: options.expiresIn ?? "7d",
156
+ });
157
+ }
158
+
159
+ export { jwt };
package/src/oauth.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { OAuthError, ValidationError } from "./errors.js";
2
+ import { builtinProviders, createProvider, getProvider, pkceVerifier } from "./providers.js";
3
+ import type {
4
+ OAuthAuthorizeOptions,
5
+ OAuthOptions,
6
+ OAuthProfile,
7
+ OAuthProvider,
8
+ OAuthProviderConfig,
9
+ OAuthStateRecord,
10
+ OAuthTokens,
11
+ StateStore,
12
+ } from "./types.js";
13
+ import { randomState, timingSafeEqualString } from "./utils.js";
14
+
15
+ interface ProviderEntry {
16
+ provider: OAuthProvider;
17
+ config: OAuthProviderConfig;
18
+ }
19
+
20
+ export class OAuth {
21
+ providers = new Map<string, ProviderEntry>();
22
+ defaultRedirectUri: string | null;
23
+ stateStore: StateStore;
24
+
25
+ constructor(options: OAuthOptions = {}) {
26
+ this.defaultRedirectUri = options.redirectUri ?? null;
27
+ this.stateStore = options.stateStore ?? createMemoryStateStore();
28
+
29
+ const configured = options.providers ?? {};
30
+ for (const [id, config] of Object.entries(configured)) {
31
+ this.use(id, config);
32
+ }
33
+ }
34
+
35
+ use(id: string, config: OAuthProviderConfig = {}): this {
36
+ const provider: OAuthProvider = (config.provider as OAuthProvider | undefined) ??
37
+ (id in builtinProviders
38
+ ? getProvider(id)
39
+ : createProvider({
40
+ id,
41
+ authorizationUrl: String(config.authorizationUrl ?? ""),
42
+ tokenUrl: String(config.tokenUrl ?? ""),
43
+ ...config,
44
+ }));
45
+
46
+ const entry: ProviderEntry = {
47
+ provider,
48
+ config: {
49
+ clientId: config.clientId,
50
+ clientSecret: config.clientSecret,
51
+ redirectUri: config.redirectUri ?? this.defaultRedirectUri ?? undefined,
52
+ scopes: config.scopes,
53
+ pkce: config.pkce,
54
+ extraAuthParams: config.extraAuthParams,
55
+ extraTokenParams: config.extraTokenParams,
56
+ userInfoHeaders: config.userInfoHeaders,
57
+ tokenAuthMethod: config.tokenAuthMethod,
58
+ },
59
+ };
60
+
61
+ this.providers.set(id, entry);
62
+ // Also expose the provider under its own id when registered via an alias,
63
+ // but never overwrite an entry that was configured explicitly.
64
+ if (provider.id && provider.id !== id && !this.providers.has(provider.id)) {
65
+ this.providers.set(provider.id, entry);
66
+ }
67
+
68
+ return this;
69
+ }
70
+
71
+ list(): string[] {
72
+ return [...new Set([...this.providers.values()].map((entry) => entry.provider.id))];
73
+ }
74
+
75
+ get(id: string): ProviderEntry {
76
+ const entry = this.providers.get(id);
77
+ if (!entry) {
78
+ throw new ValidationError(`OAuth provider "${id}" is not configured`);
79
+ }
80
+ return entry;
81
+ }
82
+
83
+ async authorize(id: string, options: OAuthAuthorizeOptions = {}): Promise<{
84
+ url: string;
85
+ state: string;
86
+ provider: string;
87
+ codeVerifier?: string;
88
+ }> {
89
+ const { provider, config } = this.get(id);
90
+ const state = options.state ?? randomState();
91
+ const redirectUri = options.redirectUri ?? config.redirectUri;
92
+ const codeVerifier = provider.pkce || config.pkce ? (options.codeVerifier ?? pkceVerifier()) : undefined;
93
+
94
+ const authorization = provider.createAuthorizationUrl(config, {
95
+ state,
96
+ redirectUri,
97
+ codeVerifier,
98
+ extraParams: options.extraParams,
99
+ });
100
+
101
+ await this.stateStore.set(state, {
102
+ provider: id,
103
+ redirectUri,
104
+ codeVerifier: authorization.codeVerifier ?? codeVerifier,
105
+ createdAt: Date.now(),
106
+ metadata: options.metadata ?? {},
107
+ } satisfies OAuthStateRecord, options.ttlSeconds ?? 600);
108
+
109
+ return {
110
+ url: authorization.url,
111
+ state,
112
+ provider: id,
113
+ codeVerifier: authorization.codeVerifier ?? codeVerifier,
114
+ };
115
+ }
116
+
117
+ async callback(id: string, params: string | Record<string, string> = {}): Promise<{
118
+ provider: string;
119
+ tokens: OAuthTokens;
120
+ profile: OAuthProfile;
121
+ state: OAuthStateRecord | null;
122
+ }> {
123
+ const query = normalizeCallbackParams(params);
124
+ if (query.error) {
125
+ throw new OAuthError(query.error_description || query.error);
126
+ }
127
+
128
+ if (!query.code) {
129
+ throw new ValidationError("OAuth callback is missing authorization code");
130
+ }
131
+
132
+ const { provider, config } = this.get(id);
133
+ let stored: OAuthStateRecord | null = null;
134
+
135
+ if (query.state) {
136
+ stored = (await this.stateStore.get(query.state)) as OAuthStateRecord | null;
137
+ if (!stored) {
138
+ throw new OAuthError("Invalid or expired OAuth state");
139
+ }
140
+ if (stored.provider && stored.provider !== id) {
141
+ throw new OAuthError("OAuth provider mismatch");
142
+ }
143
+ await this.stateStore.delete(query.state);
144
+ } else if (config.pkce || provider.pkce) {
145
+ throw new OAuthError("OAuth state is required");
146
+ }
147
+
148
+ const tokens = await provider.exchangeCode(config, {
149
+ code: query.code,
150
+ redirectUri: stored?.redirectUri ?? config.redirectUri,
151
+ codeVerifier: stored?.codeVerifier ?? query.codeVerifier,
152
+ });
153
+
154
+ const profile = await provider.fetchProfile(config, tokens);
155
+
156
+ return {
157
+ provider: id,
158
+ tokens,
159
+ profile: {
160
+ ...profile,
161
+ provider: profile.provider ?? id,
162
+ },
163
+ state: stored,
164
+ };
165
+ }
166
+
167
+ async verifyState(state: string, expected?: string): Promise<boolean> {
168
+ const stored = await this.stateStore.get(state) as OAuthStateRecord | null;
169
+ if (!stored) {
170
+ return false;
171
+ }
172
+ if (expected && !timingSafeEqualString(stored.provider, expected)) {
173
+ return false;
174
+ }
175
+ return true;
176
+ }
177
+ }
178
+
179
+ function normalizeCallbackParams(params: string | Record<string, string>): Record<string, string> {
180
+ if (typeof params === "string") {
181
+ const trimmed = params.trim();
182
+ // Accepts a full URL ("https://host/cb?code=..."), a path with a query
183
+ // ("/cb?code=..." or "cb?code=..."), a leading "?code=...", or a bare query
184
+ // string ("code=..."). Only the bare form needs special handling: the URL
185
+ // constructor reads it as a path, and reading it as a query string is the
186
+ // only way to find the code.
187
+ const isBareQuery = trimmed !== "" && !trimmed.includes("?") && !trimmed.includes("://") && !trimmed.startsWith("/");
188
+ if (isBareQuery) {
189
+ return Object.fromEntries(new URLSearchParams(trimmed));
190
+ }
191
+ const url = trimmed.includes("://") ? new URL(trimmed) : new URL(trimmed, "http://localhost");
192
+ return Object.fromEntries(url.searchParams.entries());
193
+ }
194
+ return params ?? {};
195
+ }
196
+
197
+ export function createMemoryStateStore(): StateStore {
198
+ const store = new Map<string, { value: unknown; expiresAt: number }>();
199
+
200
+ return {
201
+ async set(key, value, ttlSeconds = 600) {
202
+ store.set(key, {
203
+ value,
204
+ expiresAt: Date.now() + ttlSeconds * 1000,
205
+ });
206
+ },
207
+ async get(key) {
208
+ const entry = store.get(key);
209
+ if (!entry) {
210
+ return null;
211
+ }
212
+ if (entry.expiresAt <= Date.now()) {
213
+ store.delete(key);
214
+ return null;
215
+ }
216
+ return entry.value;
217
+ },
218
+ async delete(key) {
219
+ store.delete(key);
220
+ },
221
+ };
222
+ }
223
+
224
+ export function createOAuth(options: OAuthOptions = {}): OAuth {
225
+ return new OAuth(options);
226
+ }
@@ -0,0 +1,111 @@
1
+ import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
2
+ import { ValidationError } from "./errors.js";
3
+ import type { PasswordOptions } from "./types.js";
4
+
5
+ function scrypt(
6
+ password: string,
7
+ salt: Buffer,
8
+ keyLength: number,
9
+ options: { N: number; r: number; p: number },
10
+ ): Promise<Buffer> {
11
+ return new Promise((resolve, reject) => {
12
+ scryptCallback(password, salt, keyLength, options, (error, derivedKey) => {
13
+ if (error) {
14
+ reject(error);
15
+ return;
16
+ }
17
+ resolve(derivedKey);
18
+ });
19
+ });
20
+ }
21
+
22
+ const DEFAULT_KEY_LENGTH = 64;
23
+ const DEFAULT_SALT_BYTES = 16;
24
+ const DEFAULT_COST = 16384;
25
+ const DEFAULT_BLOCK_SIZE = 8;
26
+ const DEFAULT_PARALLELISM = 1;
27
+ const PREFIX = "scrypt";
28
+
29
+ function toBuffer(value: string | Buffer): Buffer {
30
+ return Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
31
+ }
32
+
33
+ export async function hashPassword(password: string, options: PasswordOptions = {}): Promise<string> {
34
+ if (typeof password !== "string" || password.length === 0) {
35
+ throw new ValidationError("Password must be a non-empty string");
36
+ }
37
+
38
+ const saltBytes = options.saltBytes ?? DEFAULT_SALT_BYTES;
39
+ const keyLength = options.keyLength ?? DEFAULT_KEY_LENGTH;
40
+ const N = options.cost ?? DEFAULT_COST;
41
+ const r = options.blockSize ?? DEFAULT_BLOCK_SIZE;
42
+ const p = options.parallelism ?? DEFAULT_PARALLELISM;
43
+ const salt = options.salt ? toBuffer(options.salt) : randomBytes(saltBytes);
44
+ const key = await scrypt(password, salt, keyLength, { N, r, p });
45
+
46
+ return [
47
+ PREFIX,
48
+ N,
49
+ r,
50
+ p,
51
+ keyLength,
52
+ salt.toString("base64url"),
53
+ key.toString("base64url"),
54
+ ].join("$");
55
+ }
56
+
57
+ export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
58
+ if (typeof password !== "string" || typeof storedHash !== "string") {
59
+ return false;
60
+ }
61
+
62
+ const parts = storedHash.split("$");
63
+ if (parts.length !== 7 || parts[0] !== PREFIX) {
64
+ return false;
65
+ }
66
+
67
+ const [, nRaw, rRaw, pRaw, lengthRaw, saltB64, hashB64] = parts;
68
+ const N = Number.parseInt(nRaw, 10);
69
+ const r = Number.parseInt(rRaw, 10);
70
+ const p = Number.parseInt(pRaw, 10);
71
+ const keyLength = Number.parseInt(lengthRaw, 10);
72
+
73
+ if (![N, r, p, keyLength].every(Number.isFinite)) {
74
+ return false;
75
+ }
76
+
77
+ try {
78
+ const salt = Buffer.from(saltB64, "base64url");
79
+ const expected = Buffer.from(hashB64, "base64url");
80
+ const actual = await scrypt(password, salt, keyLength, { N, r, p });
81
+ if (actual.length !== expected.length) {
82
+ return false;
83
+ }
84
+ return timingSafeEqual(actual, expected);
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ export function needsRehash(storedHash: string, options: PasswordOptions = {}): boolean {
91
+ if (typeof storedHash !== "string") {
92
+ return true;
93
+ }
94
+
95
+ const parts = storedHash.split("$");
96
+ if (parts.length !== 7 || parts[0] !== PREFIX) {
97
+ return true;
98
+ }
99
+
100
+ const N = Number.parseInt(parts[1], 10);
101
+ const r = Number.parseInt(parts[2], 10);
102
+ const p = Number.parseInt(parts[3], 10);
103
+ const keyLength = Number.parseInt(parts[4], 10);
104
+
105
+ return (
106
+ N !== (options.cost ?? DEFAULT_COST) ||
107
+ r !== (options.blockSize ?? DEFAULT_BLOCK_SIZE) ||
108
+ p !== (options.parallelism ?? DEFAULT_PARALLELISM) ||
109
+ keyLength !== (options.keyLength ?? DEFAULT_KEY_LENGTH)
110
+ );
111
+ }