@kuratchi/auth 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/README.md +44 -0
- package/package.json +19 -0
- package/src/adapter.ts +24 -0
- package/src/core/activity.ts +294 -0
- package/src/core/auth.ts +424 -0
- package/src/core/credentials.ts +665 -0
- package/src/core/guards.ts +152 -0
- package/src/core/oauth.ts +348 -0
- package/src/core/organization.ts +171 -0
- package/src/core/plugin.ts +178 -0
- package/src/core/rate-limit.ts +235 -0
- package/src/core/roles.ts +219 -0
- package/src/core/turnstile.ts +275 -0
- package/src/index.ts +69 -0
- package/src/utils/activity-actions.ts +50 -0
- package/src/utils/crypto.ts +207 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kuratchi/auth — Turnstile API
|
|
3
|
+
*
|
|
4
|
+
* Config-driven Cloudflare Turnstile bot protection.
|
|
5
|
+
* Runs in the worker entry before route handlers.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* // In kuratchi.config.ts:
|
|
10
|
+
* auth: {
|
|
11
|
+
* turnstile: {
|
|
12
|
+
* secretEnv: 'TURNSTILE_SECRET',
|
|
13
|
+
* routes: [
|
|
14
|
+
* { path: '/auth/login', methods: ['POST'] },
|
|
15
|
+
* { path: '/auth/signup', methods: ['POST'] },
|
|
16
|
+
* ]
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_FIELD_NAMES = ['cf-turnstile-response', 'turnstileToken', 'turnstile_token'];
|
|
25
|
+
const DEFAULT_HEADER_NAMES = ['cf-turnstile-token', 'x-turnstile-token'];
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Types
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
export interface TurnstileRouteConfig {
|
|
32
|
+
/** Unique identifier (defaults to path) */
|
|
33
|
+
id?: string;
|
|
34
|
+
/** Path matcher — literal path or glob with * */
|
|
35
|
+
path: string;
|
|
36
|
+
/** HTTP methods to match (defaults to ['POST']) */
|
|
37
|
+
methods?: string[];
|
|
38
|
+
/** Custom field name to read the token from */
|
|
39
|
+
tokenField?: string;
|
|
40
|
+
/** Custom header name to read the token from */
|
|
41
|
+
tokenHeader?: string;
|
|
42
|
+
/** Override failure message */
|
|
43
|
+
message?: string;
|
|
44
|
+
/** Expected Turnstile action value(s) */
|
|
45
|
+
expectedAction?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TurnstileConfig {
|
|
49
|
+
/** Env var name for Turnstile secret (default: 'TURNSTILE_SECRET') */
|
|
50
|
+
secretEnv?: string;
|
|
51
|
+
/** Env var name for Turnstile site key (default: 'TURNSTILE_SITE_KEY') — exposed to client */
|
|
52
|
+
siteKeyEnv?: string;
|
|
53
|
+
/** Skip Turnstile verification in dev mode (default: true) */
|
|
54
|
+
skipInDev?: boolean;
|
|
55
|
+
/** Routes that require Turnstile verification */
|
|
56
|
+
routes?: TurnstileRouteConfig[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Module state
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
let _config: TurnstileConfig | null = null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Configure Turnstile. Called automatically by the compiler from kuratchi.config.ts.
|
|
67
|
+
*/
|
|
68
|
+
export function configureTurnstile(config: TurnstileConfig): void {
|
|
69
|
+
_config = config;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Framework context
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
function _getContext() {
|
|
77
|
+
const dezContext = (globalThis as any).__kuratchi_context__;
|
|
78
|
+
const env = (globalThis as any).__cloudflare_env__ ?? {};
|
|
79
|
+
return {
|
|
80
|
+
request: dezContext?.request as Request | undefined,
|
|
81
|
+
locals: dezContext?.locals as Record<string, any> ?? {},
|
|
82
|
+
env,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Pattern matching
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
function _matchPath(pathname: string, pattern: string): boolean {
|
|
91
|
+
const trimmed = pattern !== '/' && pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
|
|
92
|
+
const escaped = trimmed
|
|
93
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
94
|
+
.replace(/\\\*/g, '.*');
|
|
95
|
+
return new RegExp(`^${escaped}/?$`, 'i').test(pathname);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Token extraction
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
async function _extractToken(request: Request, route: TurnstileRouteConfig): Promise<string | null> {
|
|
103
|
+
// Check headers first
|
|
104
|
+
const headerNames = route.tokenHeader ? [route.tokenHeader] : DEFAULT_HEADER_NAMES;
|
|
105
|
+
for (const name of headerNames) {
|
|
106
|
+
const val = request.headers.get(name);
|
|
107
|
+
if (val) return val;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check body
|
|
111
|
+
const contentType = request.headers.get('content-type') || '';
|
|
112
|
+
|
|
113
|
+
if (contentType.includes('application/json')) {
|
|
114
|
+
try {
|
|
115
|
+
const cloned = request.clone();
|
|
116
|
+
const json = await cloned.json() as Record<string, any>;
|
|
117
|
+
const fieldNames = route.tokenField ? [route.tokenField] : DEFAULT_FIELD_NAMES;
|
|
118
|
+
for (const name of fieldNames) {
|
|
119
|
+
if (json[name]) return String(json[name]);
|
|
120
|
+
}
|
|
121
|
+
} catch { /* ignore */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (contentType.includes('form')) {
|
|
125
|
+
try {
|
|
126
|
+
const cloned = request.clone();
|
|
127
|
+
const formData = await cloned.formData();
|
|
128
|
+
const fieldNames = route.tokenField ? [route.tokenField] : DEFAULT_FIELD_NAMES;
|
|
129
|
+
for (const name of fieldNames) {
|
|
130
|
+
const val = formData.get(name);
|
|
131
|
+
if (val) return String(val);
|
|
132
|
+
}
|
|
133
|
+
} catch { /* ignore */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// Public API
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check Turnstile verification for the current request.
|
|
145
|
+
* Called by the compiler-generated worker entry before route handlers.
|
|
146
|
+
* Returns a 403 Response if verification fails, or null to proceed.
|
|
147
|
+
*/
|
|
148
|
+
export function checkTurnstile(): Promise<Response | null> {
|
|
149
|
+
return _checkTurnstileAsync();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function _checkTurnstileAsync(): Promise<Response | null> {
|
|
153
|
+
if (!_config?.routes?.length) return null;
|
|
154
|
+
|
|
155
|
+
// Skip in dev mode (default: true)
|
|
156
|
+
if (_config.skipInDev !== false) {
|
|
157
|
+
const env = (globalThis as any).__cloudflare_env__ ?? {};
|
|
158
|
+
// Wrangler local dev sets no specific flag, but we can check if TURNSTILE_SECRET is missing
|
|
159
|
+
// or use the __DEV__ flag if the compiler sets it
|
|
160
|
+
if ((globalThis as any).__kuratchi_DEV__) return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { request, env } = _getContext();
|
|
164
|
+
if (!request) return null;
|
|
165
|
+
|
|
166
|
+
const url = new URL(request.url);
|
|
167
|
+
const method = request.method.toUpperCase();
|
|
168
|
+
const pathname = url.pathname;
|
|
169
|
+
|
|
170
|
+
// Find matching route
|
|
171
|
+
const matched = _config.routes.find(route => {
|
|
172
|
+
if (!_matchPath(pathname, route.path)) return false;
|
|
173
|
+
const methods = route.methods || ['POST'];
|
|
174
|
+
return methods.map(m => m.toUpperCase()).includes(method);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (!matched) return null;
|
|
178
|
+
|
|
179
|
+
// Resolve secret from env
|
|
180
|
+
const secretKey = _config.secretEnv || 'TURNSTILE_SECRET';
|
|
181
|
+
const secret = env[secretKey];
|
|
182
|
+
if (!secret) return null; // No secret configured — skip
|
|
183
|
+
|
|
184
|
+
// Extract token
|
|
185
|
+
const token = await _extractToken(request, matched);
|
|
186
|
+
if (!token) {
|
|
187
|
+
return new Response(JSON.stringify({
|
|
188
|
+
error: 'turnstile_token_missing',
|
|
189
|
+
message: matched.message || 'Turnstile verification required.',
|
|
190
|
+
}), {
|
|
191
|
+
status: 403,
|
|
192
|
+
headers: { 'Content-Type': 'application/json' },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Verify with Cloudflare
|
|
197
|
+
const ip = request.headers.get('cf-connecting-ip')
|
|
198
|
+
|| request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
|
199
|
+
|| undefined;
|
|
200
|
+
|
|
201
|
+
const verifyBody: Record<string, string> = { secret, response: token };
|
|
202
|
+
if (ip) verifyBody.remoteip = ip;
|
|
203
|
+
|
|
204
|
+
const verifyRes = await fetch(TURNSTILE_VERIFY_URL, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
207
|
+
body: new URLSearchParams(verifyBody),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const result: any = await verifyRes.json();
|
|
211
|
+
|
|
212
|
+
if (!result.success) {
|
|
213
|
+
return new Response(JSON.stringify({
|
|
214
|
+
error: 'turnstile_verification_failed',
|
|
215
|
+
message: matched.message || 'Bot verification failed.',
|
|
216
|
+
details: result['error-codes'],
|
|
217
|
+
}), {
|
|
218
|
+
status: 403,
|
|
219
|
+
headers: { 'Content-Type': 'application/json' },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check expected action
|
|
224
|
+
if (matched.expectedAction && result.action && result.action !== matched.expectedAction) {
|
|
225
|
+
return new Response(JSON.stringify({
|
|
226
|
+
error: 'turnstile_action_mismatch',
|
|
227
|
+
message: 'Turnstile action mismatch.',
|
|
228
|
+
}), {
|
|
229
|
+
status: 403,
|
|
230
|
+
headers: { 'Content-Type': 'application/json' },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Verify a Turnstile token manually (for use in server functions).
|
|
239
|
+
*/
|
|
240
|
+
export async function verifyTurnstile(token: string, options?: {
|
|
241
|
+
expectedAction?: string;
|
|
242
|
+
}): Promise<{ success: boolean; error?: string }> {
|
|
243
|
+
const { env } = _getContext();
|
|
244
|
+
const secretKey = _config?.secretEnv || 'TURNSTILE_SECRET';
|
|
245
|
+
const secret = env[secretKey];
|
|
246
|
+
|
|
247
|
+
if (!secret) return { success: false, error: 'TURNSTILE_SECRET not configured' };
|
|
248
|
+
|
|
249
|
+
const { request } = _getContext();
|
|
250
|
+
const ip = request?.headers.get('cf-connecting-ip') || undefined;
|
|
251
|
+
|
|
252
|
+
const verifyBody: Record<string, string> = { secret, response: token };
|
|
253
|
+
if (ip) verifyBody.remoteip = ip;
|
|
254
|
+
|
|
255
|
+
const res = await fetch(TURNSTILE_VERIFY_URL, {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
258
|
+
body: new URLSearchParams(verifyBody),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const result: any = await res.json();
|
|
262
|
+
|
|
263
|
+
if (!result.success) {
|
|
264
|
+
return { success: false, error: result['error-codes']?.join(', ') || 'Verification failed' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (options?.expectedAction && result.action !== options.expectedAction) {
|
|
268
|
+
return { success: false, error: 'Action mismatch' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { success: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kuratchi/auth — Config-driven auth for KuratchiJS
|
|
3
|
+
*
|
|
4
|
+
* All auth features are configured in kuratchi.config.ts and auto-initialized
|
|
5
|
+
* by the compiler. Import callable functions directly:
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { signUp, signIn, getCurrentUser, logActivity } from '@kuratchi/auth';
|
|
10
|
+
*
|
|
11
|
+
* const user = await getCurrentUser();
|
|
12
|
+
* await logActivity('user.login', { detail: 'Logged in' });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Core — low-level auth context (cookie/session access)
|
|
17
|
+
export { getAuth } from './core/auth.js';
|
|
18
|
+
export type { AuthContext, AuthSession, CookieOptions, GetAuthOptions } from './core/auth.js';
|
|
19
|
+
|
|
20
|
+
// Credentials — signup, signin, signout, session lookup, password reset
|
|
21
|
+
export { signUp, signIn, signOut, getCurrentUser, configureCredentials, requestPasswordReset, resetPassword } from './core/credentials.js';
|
|
22
|
+
export type { CredentialsConfig } from './core/credentials.js';
|
|
23
|
+
|
|
24
|
+
// Activity — structured audit logging
|
|
25
|
+
export { logActivity, getActivity, defineActivities, getActivityDefinitions } from './core/activity.js';
|
|
26
|
+
export type { LogActivityOptions, GetActivityOptions, ActivityConfig } from './core/activity.js';
|
|
27
|
+
|
|
28
|
+
// Roles — RBAC with permission wildcards
|
|
29
|
+
export { defineRoles, hasRole, hasPermission, getPermissionsForRole, assignRole, getRolesData, getRoleDefinitions, getAllRoles, getDefaultRole } from './core/roles.js';
|
|
30
|
+
|
|
31
|
+
// OAuth — provider-based authentication
|
|
32
|
+
export { configureOAuth, getOAuthData, startOAuth, handleOAuthCallback, getOAuthProviders } from './core/oauth.js';
|
|
33
|
+
export type { OAuthConfig } from './core/oauth.js';
|
|
34
|
+
|
|
35
|
+
// Guards — route protection (compiler interceptor)
|
|
36
|
+
export { configureGuards, checkGuard, requireAuth as requireAuthGuard } from './core/guards.js';
|
|
37
|
+
export type { GuardsConfig } from './core/guards.js';
|
|
38
|
+
|
|
39
|
+
// Rate Limiting — per-route throttling (compiler interceptor)
|
|
40
|
+
export { configureRateLimit, checkRateLimit, getRateLimitInfo } from './core/rate-limit.js';
|
|
41
|
+
export type { RateLimitConfig, RateLimitRouteConfig as RateLimitRoute } from './core/rate-limit.js';
|
|
42
|
+
|
|
43
|
+
// Turnstile — Cloudflare bot protection (compiler interceptor)
|
|
44
|
+
export { configureTurnstile, checkTurnstile, verifyTurnstile } from './core/turnstile.js';
|
|
45
|
+
export type { TurnstileConfig, TurnstileRouteConfig as TurnstileRoute } from './core/turnstile.js';
|
|
46
|
+
|
|
47
|
+
// Organization — multi-tenant DO orchestration
|
|
48
|
+
export { configureOrganization, getOrgClient, getOrgStubByName, createOrgDatabase, getOrgDatabaseInfo, resolveOrgDatabaseName, isOrgAvailable } from './core/organization.js';
|
|
49
|
+
export type { OrganizationConfig } from './core/organization.js';
|
|
50
|
+
|
|
51
|
+
// Activity action constants
|
|
52
|
+
export { ActivityAction, getActivityActions, isValidAction } from './utils/activity-actions.js';
|
|
53
|
+
|
|
54
|
+
// Crypto utilities
|
|
55
|
+
export {
|
|
56
|
+
generateSessionToken,
|
|
57
|
+
hashPassword,
|
|
58
|
+
comparePassword,
|
|
59
|
+
hashToken,
|
|
60
|
+
buildSessionCookie,
|
|
61
|
+
parseSessionCookie,
|
|
62
|
+
signState,
|
|
63
|
+
verifyState,
|
|
64
|
+
toBase64Url,
|
|
65
|
+
fromBase64Url,
|
|
66
|
+
} from './utils/crypto.js';
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity Action Constants
|
|
3
|
+
*
|
|
4
|
+
* Provides typed constants for activity actions loaded from the database.
|
|
5
|
+
* This is populated by the activityPlugin during initialization.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Cache for activity types
|
|
9
|
+
let activityTypesCache: Record<string, string> = {};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Activity Action Constants
|
|
13
|
+
* Provides autocomplete for activity actions
|
|
14
|
+
*
|
|
15
|
+
* Usage: ActivityAction.ORGANIZATION_CREATED
|
|
16
|
+
*/
|
|
17
|
+
export const ActivityAction = new Proxy({} as Record<string, string>, {
|
|
18
|
+
get(_target, prop: string) {
|
|
19
|
+
// Return cached value if available
|
|
20
|
+
if (prop in activityTypesCache) {
|
|
21
|
+
return activityTypesCache[prop];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// If not cached, convert constant name to action string
|
|
25
|
+
// ORGANIZATION_CREATED -> organization.created
|
|
26
|
+
return prop.toLowerCase().replace(/_/g, '.');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Internal: Update the activity types cache
|
|
32
|
+
* Called by activityPlugin during initialization
|
|
33
|
+
*/
|
|
34
|
+
export function _updateActivityTypesCache(types: Record<string, string>) {
|
|
35
|
+
activityTypesCache = types;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get all available activity actions
|
|
40
|
+
*/
|
|
41
|
+
export function getActivityActions(): string[] {
|
|
42
|
+
return Object.values(activityTypesCache);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if an action is valid
|
|
47
|
+
*/
|
|
48
|
+
export function isValidAction(action: string): boolean {
|
|
49
|
+
return Object.values(activityTypesCache).includes(action);
|
|
50
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kuratchi/auth — Crypto utilities
|
|
3
|
+
* Pure WebCrypto API — works in Cloudflare Workers, Node 20+, Deno, Bun
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate a secure random session token using Web Crypto API
|
|
8
|
+
* @returns A base64url encoded random string
|
|
9
|
+
*/
|
|
10
|
+
export function generateSessionToken(): string {
|
|
11
|
+
const bytes = new Uint8Array(20);
|
|
12
|
+
crypto.getRandomValues(bytes);
|
|
13
|
+
return toBase64Url(bytes);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hash a password with PBKDF2 + optional pepper
|
|
18
|
+
* Returns "saltHex:hashHex"
|
|
19
|
+
*/
|
|
20
|
+
export const hashPassword = async (password: string, providedSalt?: Uint8Array, pepper?: string): Promise<string> => {
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
const salt = providedSalt || crypto.getRandomValues(new Uint8Array(16));
|
|
23
|
+
|
|
24
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
25
|
+
'raw',
|
|
26
|
+
encoder.encode(pepper ? `${password}:${pepper}` : password),
|
|
27
|
+
{ name: 'PBKDF2' },
|
|
28
|
+
false,
|
|
29
|
+
['deriveBits', 'deriveKey']
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const key = await crypto.subtle.deriveKey(
|
|
33
|
+
{
|
|
34
|
+
name: 'PBKDF2',
|
|
35
|
+
salt: salt.buffer as ArrayBuffer,
|
|
36
|
+
iterations: 100000,
|
|
37
|
+
hash: 'SHA-256',
|
|
38
|
+
},
|
|
39
|
+
keyMaterial,
|
|
40
|
+
{ name: 'AES-GCM', length: 256 },
|
|
41
|
+
true,
|
|
42
|
+
['encrypt', 'decrypt']
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const exportedKey = await crypto.subtle.exportKey('raw', key);
|
|
46
|
+
const hashArray = Array.from(new Uint8Array(exportedKey));
|
|
47
|
+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
48
|
+
|
|
49
|
+
const saltArray = Array.from(salt);
|
|
50
|
+
const saltHex = saltArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
51
|
+
|
|
52
|
+
return `${saltHex}:${hashHex}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compare a plaintext password against a stored hash
|
|
57
|
+
*/
|
|
58
|
+
export const comparePassword = async (password: string, hashedPassword: string, pepper?: string): Promise<boolean> => {
|
|
59
|
+
const [salt, hash] = hashedPassword.split(':');
|
|
60
|
+
const saltMatches = salt.match(/.{1,2}/g);
|
|
61
|
+
if (!saltMatches) return false;
|
|
62
|
+
const saltBuffer = new Uint8Array(saltMatches.map(byte => parseInt(byte, 16)));
|
|
63
|
+
|
|
64
|
+
const _hash = await hashPassword(password, saltBuffer, pepper);
|
|
65
|
+
const [, _hashHex] = _hash.split(':');
|
|
66
|
+
|
|
67
|
+
return _hashHex === hash;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* SHA-256 hash a token string
|
|
72
|
+
*/
|
|
73
|
+
export const hashToken = async (token: string): Promise<string> => {
|
|
74
|
+
const encoder = new TextEncoder();
|
|
75
|
+
const data = encoder.encode(token);
|
|
76
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
77
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
78
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ==============================
|
|
82
|
+
// Base64url helpers
|
|
83
|
+
// ==============================
|
|
84
|
+
|
|
85
|
+
const _toBytes = (input: string | ArrayBuffer | Uint8Array) =>
|
|
86
|
+
typeof input === 'string' ? new TextEncoder().encode(input) : (input instanceof Uint8Array ? input : new Uint8Array(input));
|
|
87
|
+
|
|
88
|
+
const _bytesToBase64 = (bytes: Uint8Array) => {
|
|
89
|
+
const B = (globalThis as any).Buffer;
|
|
90
|
+
if (B) return B.from(bytes).toString('base64');
|
|
91
|
+
let binary = '';
|
|
92
|
+
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
|
93
|
+
return btoa(binary);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const _base64ToBytes = (b64: string) => {
|
|
97
|
+
const B = (globalThis as any).Buffer;
|
|
98
|
+
if (B) return new Uint8Array(B.from(b64, 'base64'));
|
|
99
|
+
const binary = atob(b64);
|
|
100
|
+
const bytes = new Uint8Array(binary.length);
|
|
101
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
102
|
+
return bytes;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const toBase64Url = (buf: ArrayBuffer | Uint8Array | string) =>
|
|
106
|
+
_bytesToBase64(_toBytes(buf)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
107
|
+
|
|
108
|
+
export const fromBase64Url = (b64url: string): Uint8Array => {
|
|
109
|
+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(b64url.length / 4) * 4, '=');
|
|
110
|
+
return _base64ToBytes(b64);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function b64urlDecodeToString(str: string): string {
|
|
114
|
+
const decoded = fromBase64Url(str);
|
|
115
|
+
return new TextDecoder().decode(decoded);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ==============================
|
|
119
|
+
// Session cookie encryption (AES-GCM)
|
|
120
|
+
// ==============================
|
|
121
|
+
|
|
122
|
+
const importAesGcmKey = async (secret: string): Promise<CryptoKey> => {
|
|
123
|
+
if (!secret) {
|
|
124
|
+
throw new Error('[kuratchi/auth] Cannot derive encryption key from empty secret. Set AUTH_SECRET.');
|
|
125
|
+
}
|
|
126
|
+
const enc = new TextEncoder();
|
|
127
|
+
const raw = await crypto.subtle.digest('SHA-256', enc.encode(secret));
|
|
128
|
+
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build an opaque encrypted session cookie from orgId and tokenHash
|
|
133
|
+
*/
|
|
134
|
+
export const buildSessionCookie = async (
|
|
135
|
+
secret: string,
|
|
136
|
+
orgId: string,
|
|
137
|
+
tokenHash: string
|
|
138
|
+
): Promise<string> => {
|
|
139
|
+
const key = await importAesGcmKey(secret);
|
|
140
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
141
|
+
const payload = JSON.stringify({ o: orgId, th: tokenHash });
|
|
142
|
+
const pt = new TextEncoder().encode(payload);
|
|
143
|
+
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, pt);
|
|
144
|
+
const combined = new Uint8Array(iv.byteLength + (ct as ArrayBuffer).byteLength);
|
|
145
|
+
combined.set(iv, 0);
|
|
146
|
+
combined.set(new Uint8Array(ct), iv.byteLength);
|
|
147
|
+
return toBase64Url(combined);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse an opaque session cookie into { orgId, tokenHash }
|
|
152
|
+
*/
|
|
153
|
+
export const parseSessionCookie = async (
|
|
154
|
+
secret: string,
|
|
155
|
+
cookie: string
|
|
156
|
+
): Promise<{ orgId: string; tokenHash: string } | null> => {
|
|
157
|
+
try {
|
|
158
|
+
if (!cookie) return null;
|
|
159
|
+
const data = fromBase64Url(cookie);
|
|
160
|
+
if (data.byteLength < 13) return null;
|
|
161
|
+
const iv = data.slice(0, 12);
|
|
162
|
+
const ct = data.slice(12);
|
|
163
|
+
const key = await importAesGcmKey(secret);
|
|
164
|
+
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
|
165
|
+
const json = new TextDecoder().decode(pt);
|
|
166
|
+
const parsed = JSON.parse(json);
|
|
167
|
+
if (!parsed?.o || !parsed?.th) return null;
|
|
168
|
+
return { orgId: parsed.o, tokenHash: parsed.th };
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// ==============================
|
|
175
|
+
// HMAC state signing (for OAuth)
|
|
176
|
+
// ==============================
|
|
177
|
+
|
|
178
|
+
export const importHmacKey = async (secret: string): Promise<CryptoKey> =>
|
|
179
|
+
crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
|
180
|
+
|
|
181
|
+
export const signState = async (secret: string, payload: Record<string, any>): Promise<string> => {
|
|
182
|
+
const key = await importHmacKey(secret);
|
|
183
|
+
const json = JSON.stringify(payload);
|
|
184
|
+
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(json));
|
|
185
|
+
return `${toBase64Url(json)}.${toBase64Url(new Uint8Array(sig))}`;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const verifyState = async (secret: string, state: string): Promise<Record<string, any> | null> => {
|
|
189
|
+
try {
|
|
190
|
+
const [p, s] = state.split('.', 2);
|
|
191
|
+
if (!p || !s) return null;
|
|
192
|
+
const json = b64urlDecodeToString(p);
|
|
193
|
+
const key = await importHmacKey(secret);
|
|
194
|
+
const sigBytes = fromBase64Url(s);
|
|
195
|
+
const sigCopy = new Uint8Array(sigBytes.byteLength);
|
|
196
|
+
sigCopy.set(sigBytes);
|
|
197
|
+
const sigBuf: ArrayBuffer = sigCopy.buffer;
|
|
198
|
+
const valid = await crypto.subtle.verify('HMAC', key, sigBuf, new TextEncoder().encode(json));
|
|
199
|
+
if (!valid) return null;
|
|
200
|
+
return JSON.parse(json);
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|