@holo-js/auth-social 0.1.3 → 0.1.5

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/dist/index.d.ts CHANGED
@@ -36,11 +36,40 @@ interface SocialProviderRuntime {
36
36
  readonly tokens: SocialProviderTokens;
37
37
  }>;
38
38
  }
39
+ type SocialRequestHeaders = Headers | ReadonlyArray<readonly [string, string]> | Record<string, string | readonly string[] | undefined> | {
40
+ readonly get?: (name: string) => string | null | undefined;
41
+ readonly forEach?: (callback: (value: string, key: string) => void) => void;
42
+ readonly entries?: () => Iterable<readonly [string, string]>;
43
+ };
44
+ type SocialRequestLike = {
45
+ readonly method?: string;
46
+ readonly path?: string;
47
+ readonly url?: string | URL;
48
+ readonly headers?: SocialRequestHeaders;
49
+ readonly request?: Request;
50
+ readonly req?: Request | {
51
+ readonly method?: string;
52
+ readonly url?: string;
53
+ readonly headers?: SocialRequestHeaders;
54
+ };
55
+ readonly node?: {
56
+ readonly req?: {
57
+ readonly method?: string;
58
+ readonly url?: string;
59
+ readonly headers?: SocialRequestHeaders;
60
+ };
61
+ };
62
+ readonly web?: {
63
+ readonly request?: Request;
64
+ };
65
+ };
66
+ type SocialRequestInput = Request | SocialRequestLike;
39
67
  interface SocialPendingStateRecord {
40
68
  readonly provider: string;
41
69
  readonly state: string;
42
70
  readonly codeVerifier: string;
43
71
  readonly guard: string;
72
+ readonly browserBinding?: string;
44
73
  readonly createdAt: Date;
45
74
  }
46
75
  interface SocialPendingStateStore {
@@ -72,8 +101,21 @@ interface SocialAuthBindings {
72
101
  readonly encryptionKey?: string;
73
102
  }
74
103
  interface SocialAuthFacade {
75
- redirect(provider: string, request: Request): Promise<Response>;
76
- callback(provider: string, request: Request): Promise<Response>;
104
+ redirect(provider: string, request: SocialRequestInput): Promise<Response>;
105
+ callback(provider: string, request: SocialRequestInput): Promise<SocialCallbackResult>;
106
+ }
107
+ type SocialCallbackResult = SocialCallbackSuccess | SocialCallbackFailure;
108
+ interface SocialCallbackSuccess {
109
+ readonly ok: true;
110
+ readonly guard: string;
111
+ readonly authProvider: string;
112
+ readonly provider: string;
113
+ readonly user: AuthUserLike;
114
+ }
115
+ interface SocialCallbackFailure {
116
+ readonly ok: false;
117
+ readonly status: 400;
118
+ readonly message: string;
77
119
  }
78
120
  declare function getBindings(): SocialAuthBindings;
79
121
  declare function createState(): string;
@@ -89,8 +131,8 @@ declare function resolveLinkedUser(provider: string, profile: SocialProviderProf
89
131
  readonly authProvider: string;
90
132
  readonly user: AuthUserLike;
91
133
  }>;
92
- declare function redirect(provider: string, request: Request): Promise<Response>;
93
- declare function callback(provider: string, request: Request): Promise<Response>;
134
+ declare function redirect(provider: string, input: SocialRequestInput): Promise<Response>;
135
+ declare function callback(provider: string, input: SocialRequestInput): Promise<SocialCallbackResult>;
94
136
  declare function configureSocialAuthRuntime(bindings?: SocialAuthBindings): void;
95
137
  declare function resetSocialAuthRuntime(): void;
96
138
  declare const socialAuth: Readonly<{
@@ -108,4 +150,4 @@ declare const socialAuthInternals: {
108
150
  resolveLinkedUser: typeof resolveLinkedUser;
109
151
  };
110
152
 
111
- export { type SocialAuthBindings, type SocialAuthFacade, type SocialCallbackContext, type SocialIdentityRecord, type SocialIdentityStore, type SocialPendingStateRecord, type SocialPendingStateStore, type SocialProviderProfile, type SocialProviderRuntime, type SocialProviderTokens, type SocialRedirectContext, callback, configureSocialAuthRuntime, decryptTokens, redirect, resetSocialAuthRuntime, socialAuth, socialAuthInternals };
153
+ export { type SocialAuthBindings, type SocialAuthFacade, type SocialCallbackContext, type SocialCallbackFailure, type SocialCallbackResult, type SocialCallbackSuccess, type SocialIdentityRecord, type SocialIdentityStore, type SocialPendingStateRecord, type SocialPendingStateStore, type SocialProviderProfile, type SocialProviderRuntime, type SocialProviderTokens, type SocialRedirectContext, type SocialRequestHeaders, type SocialRequestInput, type SocialRequestLike, callback, configureSocialAuthRuntime, decryptTokens, redirect, resetSocialAuthRuntime, socialAuth, socialAuthInternals };
package/dist/index.mjs CHANGED
@@ -1,8 +1,116 @@
1
1
  // src/index.ts
2
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from "crypto";
3
3
  import { authRuntimeInternals } from "@holo-js/auth";
4
- var socialBindings;
4
+ var SOCIAL_BINDINGS_KEY = "__holoAuthSocialBindings__";
5
5
  var AUTH_PROVIDER_MARKER = /* @__PURE__ */ Symbol.for("holo-js.auth.provider");
6
+ var GET_ONLY_REQUEST_HEADER_NAMES = ["authorization", "cookie", "host", "x-forwarded-host", "x-forwarded-proto"];
7
+ function getSocialRuntimeGlobal() {
8
+ return globalThis;
9
+ }
10
+ function isPlainHeaderRecord(value) {
11
+ return Boolean(value) && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
12
+ }
13
+ function appendKnownHeaders(headers, input) {
14
+ for (const name of GET_ONLY_REQUEST_HEADER_NAMES) {
15
+ const value = input.get?.(name);
16
+ if (typeof value === "string" && value) {
17
+ headers.set(name, value);
18
+ }
19
+ }
20
+ }
21
+ function hasHeaderForEach(input) {
22
+ return !Array.isArray(input) && "forEach" in input && typeof input.forEach === "function";
23
+ }
24
+ function hasHeaderEntries(input) {
25
+ return !Array.isArray(input) && "entries" in input && typeof input.entries === "function";
26
+ }
27
+ function hasHeaderGet(input) {
28
+ return !Array.isArray(input) && "get" in input && typeof input.get === "function";
29
+ }
30
+ function normalizeRequestHeaders(input) {
31
+ const headers = new Headers();
32
+ if (!input) {
33
+ return headers;
34
+ }
35
+ if (input instanceof Headers || Array.isArray(input)) {
36
+ new Headers(input).forEach((value, name) => headers.append(name, value));
37
+ return headers;
38
+ }
39
+ if (hasHeaderForEach(input)) {
40
+ input.forEach((value, name) => headers.append(name, value));
41
+ return headers;
42
+ }
43
+ if (hasHeaderEntries(input)) {
44
+ for (const [name, value] of input.entries()) {
45
+ headers.append(name, value);
46
+ }
47
+ return headers;
48
+ }
49
+ if (hasHeaderGet(input)) {
50
+ appendKnownHeaders(headers, input);
51
+ return headers;
52
+ }
53
+ if (isPlainHeaderRecord(input)) {
54
+ for (const [name, value] of Object.entries(input)) {
55
+ if (typeof value === "string") {
56
+ headers.append(name, value);
57
+ continue;
58
+ }
59
+ if (Array.isArray(value)) {
60
+ const separator = name.toLowerCase() === "cookie" ? "; " : ",";
61
+ const joined = value.filter((entry) => typeof entry === "string").join(separator);
62
+ if (joined) {
63
+ headers.append(name, joined);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ return headers;
69
+ }
70
+ function getRequestFromLikeInput(input) {
71
+ return input.request ?? input.web?.request ?? (input.req instanceof Request ? input.req : void 0);
72
+ }
73
+ function getRequestLikeHeaders(input) {
74
+ return input.headers ?? (typeof input.req === "object" && !(input.req instanceof Request) ? input.req.headers : void 0) ?? input.node?.req?.headers;
75
+ }
76
+ function getRequestLikeMethod(input) {
77
+ return input.method ?? (typeof input.req === "object" && !(input.req instanceof Request) ? input.req.method : void 0) ?? input.node?.req?.method ?? "GET";
78
+ }
79
+ function isProductionRuntime() {
80
+ return process.env.NODE_ENV === "production";
81
+ }
82
+ function createRelativeRequestBaseUrl(headers) {
83
+ const forwardedProtocol = headers.get("x-forwarded-proto");
84
+ const forwardedHost = headers.get("x-forwarded-host");
85
+ if (isProductionRuntime() && (!forwardedProtocol || !forwardedHost)) {
86
+ throw new Error("[@holo-js/auth-social] Relative request URLs require x-forwarded-proto and x-forwarded-host headers in production.");
87
+ }
88
+ const protocol = forwardedProtocol ?? "http";
89
+ const host = forwardedHost ?? headers.get("host") ?? "localhost";
90
+ return `${protocol}://${host}`;
91
+ }
92
+ function getRequestLikeUrl(input, headers) {
93
+ const url = (typeof input.url === "string" ? input.url : input.url?.toString()) ?? (typeof input.req === "object" && !(input.req instanceof Request) ? input.req.url : void 0) ?? input.node?.req?.url ?? input.path ?? "/";
94
+ try {
95
+ return new URL(url).toString();
96
+ } catch {
97
+ return new URL(url, createRelativeRequestBaseUrl(headers)).toString();
98
+ }
99
+ }
100
+ function normalizeSocialRequest(input) {
101
+ if (input instanceof Request) {
102
+ return input;
103
+ }
104
+ const request = getRequestFromLikeInput(input);
105
+ if (request) {
106
+ return request;
107
+ }
108
+ const headers = normalizeRequestHeaders(getRequestLikeHeaders(input));
109
+ return new Request(getRequestLikeUrl(input, headers), {
110
+ method: getRequestLikeMethod(input),
111
+ headers
112
+ });
113
+ }
6
114
  function requireUserRecord(user, message) {
7
115
  if (user == null) {
8
116
  throw new Error(message);
@@ -37,6 +145,7 @@ function throwUnconfigured() {
37
145
  throw new Error("[@holo-js/auth-social] Social auth runtime is not configured yet.");
38
146
  }
39
147
  function getBindings() {
148
+ const socialBindings = getSocialRuntimeGlobal()[SOCIAL_BINDINGS_KEY];
40
149
  if (!socialBindings) {
41
150
  throwUnconfigured();
42
151
  }
@@ -45,12 +154,83 @@ function getBindings() {
45
154
  function createState() {
46
155
  return randomBytes(24).toString("base64url");
47
156
  }
157
+ function createBrowserBindingNonce() {
158
+ return createState();
159
+ }
160
+ function hashBrowserBinding(nonce) {
161
+ return createHash("sha256").update(nonce).digest("base64url");
162
+ }
48
163
  function createCodeVerifier() {
49
164
  return randomBytes(32).toString("base64url");
50
165
  }
51
166
  function createCodeChallenge(verifier) {
52
167
  return createHash("sha256").update(verifier).digest("base64url");
53
168
  }
169
+ function getStateCookieName(provider) {
170
+ const suffix = provider.replace(/[^a-zA-Z0-9_-]/g, "_");
171
+ return `holo_oauth_state_${suffix}`;
172
+ }
173
+ function serializeStateCookie(provider, state, nonce, request) {
174
+ const secure = new URL(request.url).protocol === "https:";
175
+ const attributes = [
176
+ `${getStateCookieName(provider)}=${state}.${nonce}`,
177
+ "Path=/",
178
+ "HttpOnly",
179
+ "SameSite=Lax",
180
+ "Max-Age=600"
181
+ ];
182
+ if (secure) {
183
+ attributes.push("Secure");
184
+ }
185
+ return attributes.join("; ");
186
+ }
187
+ function readCookie(request, name) {
188
+ const header = request.headers.get("cookie");
189
+ if (!header) {
190
+ return void 0;
191
+ }
192
+ for (const entry of header.split(";")) {
193
+ const separatorIndex = entry.indexOf("=");
194
+ if (separatorIndex < 0) {
195
+ continue;
196
+ }
197
+ const cookieName = entry.slice(0, separatorIndex).trim();
198
+ if (cookieName !== name) {
199
+ continue;
200
+ }
201
+ return entry.slice(separatorIndex + 1).trim();
202
+ }
203
+ return void 0;
204
+ }
205
+ function readStateCookie(request, provider) {
206
+ const value = readCookie(request, getStateCookieName(provider));
207
+ if (!value) {
208
+ return null;
209
+ }
210
+ const separatorIndex = value.indexOf(".");
211
+ if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
212
+ return null;
213
+ }
214
+ return {
215
+ state: value.slice(0, separatorIndex),
216
+ nonce: value.slice(separatorIndex + 1)
217
+ };
218
+ }
219
+ function timingSafeStringEqual(left, right) {
220
+ const leftBuffer = Buffer.from(left);
221
+ const rightBuffer = Buffer.from(right);
222
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
223
+ }
224
+ function verifyBrowserBinding(provider, state, pending, request) {
225
+ if (!pending.browserBinding) {
226
+ return false;
227
+ }
228
+ const cookie = readStateCookie(request, provider);
229
+ if (!cookie || cookie.state !== state) {
230
+ return false;
231
+ }
232
+ return timingSafeStringEqual(hashBrowserBinding(cookie.nonce), pending.browserBinding);
233
+ }
54
234
  function encryptTokens(value, encryptionKey) {
55
235
  if (typeof encryptionKey !== "string" || !encryptionKey.trim()) {
56
236
  throw new Error("[@holo-js/auth-social] encryptionKey is required when encryptTokens is enabled.");
@@ -113,9 +293,6 @@ function resolveGuardAndProvider(provider) {
113
293
  if (!guard) {
114
294
  throw new Error(`[@holo-js/auth-social] Guard "${guardName}" is not configured for social provider "${provider}".`);
115
295
  }
116
- if (guard.driver !== "session") {
117
- throw new Error(`[@holo-js/auth-social] Social sign-in requires auth guard "${guardName}" to use the session driver.`);
118
- }
119
296
  const authProvider = providerConfig.mapToProvider ?? guard.provider;
120
297
  const adapter = authBindings.providers[authProvider];
121
298
  if (!adapter) {
@@ -161,19 +338,25 @@ function resolveEmailForCreation(provider, profile, options = {}) {
161
338
  }
162
339
  async function resolveLinkedUser(provider, profile, tokens) {
163
340
  const bindings = getBindings();
164
- const { guard, authProvider, adapter } = resolveGuardAndProvider(provider);
165
341
  const existingIdentity = await bindings.identityStore.findByProviderUserId(provider, profile.id);
166
342
  const authBindings = authRuntimeInternals.getRuntimeBindings();
167
343
  const verificationRequired = authBindings.config.emailVerification.required === true;
168
344
  if (existingIdentity) {
345
+ if (!authBindings.config.guards[existingIdentity.guard]) {
346
+ throw new Error(`[@holo-js/auth-social] Guard "${existingIdentity.guard}" is not configured for linked social identity "${provider}:${profile.id}".`);
347
+ }
348
+ const storedAdapter = authBindings.providers[existingIdentity.authProvider];
349
+ if (!storedAdapter) {
350
+ throw new Error(`[@holo-js/auth-social] Auth provider runtime "${existingIdentity.authProvider}" is not configured for linked social identity "${provider}:${profile.id}".`);
351
+ }
169
352
  const linkedUser = resolveUserRecord(
170
- await adapter.findById(existingIdentity.userId),
353
+ await storedAdapter.findById(existingIdentity.userId),
171
354
  `[@holo-js/auth-social] Linked social identity "${provider}:${profile.id}" references a missing local user.`
172
355
  );
173
356
  if (!linkedUser) {
174
357
  throw new Error(`[@holo-js/auth-social] Linked social identity "${provider}:${profile.id}" references a missing local user.`);
175
358
  }
176
- const serialized2 = serializeLocalUser(adapter, linkedUser, authProvider);
359
+ const serialized2 = serializeLocalUser(storedAdapter, linkedUser, existingIdentity.authProvider);
177
360
  await bindings.identityStore.save({
178
361
  ...existingIdentity,
179
362
  email: profile.email,
@@ -187,8 +370,13 @@ async function resolveLinkedUser(provider, profile, tokens) {
187
370
  tokens: getConfiguredProviderConfig(provider).encryptTokens ? encryptTokens(tokens, bindings.encryptionKey) : tokens,
188
371
  updatedAt: /* @__PURE__ */ new Date()
189
372
  });
190
- return { guard, authProvider, user: serialized2 };
373
+ return {
374
+ guard: existingIdentity.guard,
375
+ authProvider: existingIdentity.authProvider,
376
+ user: serialized2
377
+ };
191
378
  }
379
+ const { guard, authProvider, adapter } = resolveGuardAndProvider(provider);
192
380
  const hasVerifiedEmail = profile.emailVerified === true && typeof profile.email === "string" && profile.email.trim().length > 0;
193
381
  if (!hasVerifiedEmail && verificationRequired) {
194
382
  throw new Error(`[@holo-js/auth-social] Social sign-in with "${provider}" requires a verified email address.`);
@@ -231,11 +419,13 @@ async function resolveLinkedUser(provider, profile, tokens) {
231
419
  user: serialized
232
420
  };
233
421
  }
234
- async function redirect(provider, request) {
422
+ async function redirect(provider, input) {
423
+ const request = normalizeSocialRequest(input);
235
424
  const providerConfig = getConfiguredProviderConfig(provider);
236
425
  const runtime = getProviderRuntime(provider);
237
426
  const { guard } = resolveGuardAndProvider(provider);
238
427
  const state = createState();
428
+ const browserNonce = createBrowserBindingNonce();
239
429
  const codeVerifier = createCodeVerifier();
240
430
  const codeChallenge = createCodeChallenge(codeVerifier);
241
431
  await getBindings().stateStore.create({
@@ -243,6 +433,7 @@ async function redirect(provider, request) {
243
433
  state,
244
434
  codeVerifier,
245
435
  guard,
436
+ browserBinding: hashBrowserBinding(browserNonce),
246
437
  createdAt: /* @__PURE__ */ new Date()
247
438
  });
248
439
  const authorizationUrl = await runtime.buildAuthorizationUrl({
@@ -253,11 +444,13 @@ async function redirect(provider, request) {
253
444
  codeChallenge,
254
445
  config: providerConfig
255
446
  });
447
+ const headers = new Headers({
448
+ location: authorizationUrl
449
+ });
450
+ headers.append("set-cookie", serializeStateCookie(provider, state, browserNonce, request));
256
451
  return new Response(null, {
257
452
  status: 302,
258
- headers: {
259
- location: authorizationUrl
260
- }
453
+ headers
261
454
  });
262
455
  }
263
456
  async function readCallbackParameters(request) {
@@ -286,18 +479,30 @@ async function readCallbackParameters(request) {
286
479
  code: formCode ?? queryCode
287
480
  };
288
481
  }
289
- async function callback(provider, request) {
482
+ async function callback(provider, input) {
483
+ const request = normalizeSocialRequest(input);
290
484
  const { state, code } = await readCallbackParameters(request);
291
485
  if (!state || !code) {
292
- return Response.json({
486
+ return {
487
+ ok: false,
488
+ status: 400,
293
489
  message: "Missing OAuth state or code."
294
- }, { status: 400 });
490
+ };
295
491
  }
296
492
  const pending = await getBindings().stateStore.read(provider, state);
297
493
  if (!pending) {
298
- return Response.json({
494
+ return {
495
+ ok: false,
496
+ status: 400,
299
497
  message: "Invalid or expired OAuth state."
300
- }, { status: 400 });
498
+ };
499
+ }
500
+ if (!verifyBrowserBinding(provider, state, pending, request)) {
501
+ return {
502
+ ok: false,
503
+ status: 400,
504
+ message: "Invalid or expired OAuth state."
505
+ };
301
506
  }
302
507
  await getBindings().stateStore.delete(provider, state);
303
508
  const runtime = getProviderRuntime(provider);
@@ -310,29 +515,19 @@ async function callback(provider, request) {
310
515
  config: providerConfig
311
516
  });
312
517
  const linked = await resolveLinkedUser(provider, exchanged.profile, exchanged.tokens);
313
- const established = await authRuntimeInternals.establishSessionForUser(linked.user, {
314
- guard: linked.guard,
315
- provider: linked.authProvider
316
- });
317
- const headers = new Headers();
318
- for (const cookie of established.cookies) {
319
- headers.append("set-cookie", cookie);
320
- }
321
- return Response.json({
322
- authenticated: true,
518
+ return {
519
+ ok: true,
323
520
  guard: linked.guard,
521
+ authProvider: linked.authProvider,
324
522
  provider,
325
523
  user: linked.user
326
- }, {
327
- status: 200,
328
- headers
329
- });
524
+ };
330
525
  }
331
526
  function configureSocialAuthRuntime(bindings) {
332
- socialBindings = bindings;
527
+ getSocialRuntimeGlobal()[SOCIAL_BINDINGS_KEY] = bindings;
333
528
  }
334
529
  function resetSocialAuthRuntime() {
335
- socialBindings = void 0;
530
+ delete getSocialRuntimeGlobal()[SOCIAL_BINDINGS_KEY];
336
531
  }
337
532
  var socialAuth = Object.freeze({
338
533
  redirect,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holo-js/auth-social",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Holo-JS Framework - social auth provider contracts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,13 +23,13 @@
23
23
  "test": "vitest --run"
24
24
  },
25
25
  "dependencies": {
26
- "@holo-js/auth": "^0.1.3",
27
- "@holo-js/config": "^0.1.3"
26
+ "@holo-js/auth": "^0.1.5",
27
+ "@holo-js/config": "^0.1.5"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^22.10.2",
31
31
  "tsup": "^8.3.5",
32
32
  "typescript": "^5.7.2",
33
- "vitest": "^2.1.8"
33
+ "vitest": "^4.1.5"
34
34
  }
35
35
  }