@codelitdev/oauth-server-kit 0.1.0-alpha.0 → 0.1.0-alpha.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 CHANGED
@@ -8,17 +8,6 @@ The library authenticates people and OAuth clients. Each product continues to
8
8
  own its users-to-actor mapping, organizations, teams, memberships, roles,
9
9
  permissions, API keys, and credential precedence.
10
10
 
11
- ## Install
12
-
13
- The package is currently released through npm's `alpha` channel:
14
-
15
- ```sh
16
- pnpm add @codelitdev/oauth-server-kit@alpha
17
- ```
18
-
19
- It has a pre-1.0 public API. Pin an explicit version for production consumers
20
- and upgrade deliberately.
21
-
22
11
  ## Supported surfaces
23
12
 
24
13
  - Web apps use product-local Better Auth sessions.
@@ -85,14 +74,3 @@ pnpm build
85
74
  The coverage release gate is 90% statements, lines, and functions and 80%
86
75
  branches. The suite includes a real Better Auth OAuth Provider flow with a
87
76
  public client, S256 PKCE, refresh, JWKS verification, and MCP discovery.
88
-
89
- ## Releasing an alpha
90
-
91
- After updating the package version, run:
92
-
93
- ```sh
94
- npm run release:alpha -- --otp=<npm-otp>
95
- ```
96
-
97
- The command validates formatting, linting, types, tests, coverage, and the
98
- build before publishing to npm with the `alpha` dist-tag.
@@ -30,6 +30,9 @@ interface CreateOAuthPagesRouterOptions {
30
30
  defaultRedirectUrl: string;
31
31
  loginMethods: readonly HostedLoginMethod[];
32
32
  legacyHostOnlySessionCookieNames?: readonly string[];
33
+ logoUrl?: string;
34
+ faviconUrl?: string;
35
+ primaryColor?: string;
33
36
  }
34
37
  declare function createOAuthPagesRouter(rawOptions: CreateOAuthPagesRouterOptions): Router;
35
38
 
package/dist/express.d.ts CHANGED
@@ -30,6 +30,9 @@ interface CreateOAuthPagesRouterOptions {
30
30
  defaultRedirectUrl: string;
31
31
  loginMethods: readonly HostedLoginMethod[];
32
32
  legacyHostOnlySessionCookieNames?: readonly string[];
33
+ logoUrl?: string;
34
+ faviconUrl?: string;
35
+ primaryColor?: string;
33
36
  }
34
37
  declare function createOAuthPagesRouter(rawOptions: CreateOAuthPagesRouterOptions): Router;
35
38
 
package/dist/express.js CHANGED
@@ -209,6 +209,41 @@ function absoluteOrigin(value, field) {
209
209
  }
210
210
  return url.origin;
211
211
  }
212
+ var HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
213
+ var OKLCH_COLOR = /^oklch\([0-9.%\s/,+-]+\)$/i;
214
+ function brandingColor(value, field) {
215
+ if (value === void 0) return void 0;
216
+ const color = value.trim();
217
+ if (HEX_COLOR.test(color) || OKLCH_COLOR.test(color)) return color;
218
+ throw new Error(`${field} must be a hex or oklch() color`);
219
+ }
220
+ function brandingUrl(value, field) {
221
+ if (value === void 0) return void 0;
222
+ const raw = value.trim();
223
+ if (!raw) throw new Error(`${field} must be a URL`);
224
+ if (raw.startsWith("/") && !raw.startsWith("//")) {
225
+ if (/\s/.test(raw) || raw.includes("\\") || raw.includes("<")) {
226
+ throw new Error(`${field} must be a safe URL`);
227
+ }
228
+ return raw;
229
+ }
230
+ let url;
231
+ try {
232
+ url = new URL(raw);
233
+ } catch (e) {
234
+ throw new Error(`${field} must be an absolute URL`);
235
+ }
236
+ if (!["http:", "https:"].includes(url.protocol)) {
237
+ throw new Error(`${field} must use http or https`);
238
+ }
239
+ if (process.env.NODE_ENV === "production" && url.protocol !== "https:") {
240
+ throw new Error(`${field} must use https in production`);
241
+ }
242
+ if (url.username || url.password) {
243
+ throw new Error(`${field} must not contain credentials`);
244
+ }
245
+ return url.toString();
246
+ }
212
247
  function normalizeOptions(options) {
213
248
  var _a;
214
249
  if (!options.appName.trim()) throw new Error("appName is required");
@@ -263,19 +298,42 @@ function normalizeOptions(options) {
263
298
  appName: options.appName.trim(),
264
299
  authBasePath,
265
300
  allowedRedirectOrigins,
266
- defaultRedirectUrl: defaultRedirect.toString()
301
+ defaultRedirectUrl: defaultRedirect.toString(),
302
+ logoUrl: brandingUrl(options.logoUrl, "logoUrl"),
303
+ faviconUrl: brandingUrl(options.faviconUrl, "faviconUrl"),
304
+ primaryColor: brandingColor(options.primaryColor, "primaryColor")
267
305
  });
268
306
  }
269
307
  var STYLES = `
270
- *{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:#0b0b0b;color:#fff;font-family:system-ui,sans-serif}
271
- .card{width:100%;max-width:400px;padding:32px;border:1px solid #292929;border-radius:14px;background:#111}.logo{width:38px;height:38px;margin:0 auto 20px;display:grid;place-items:center;border-radius:50%;background:#fff;color:#111;font-weight:700}
272
- h1{margin:0 0 8px;text-align:center;font-size:22px}.sub{margin:0 0 24px;text-align:center;color:#aaa;font-size:13px;line-height:1.5}.error{display:none;margin-bottom:16px;padding:10px 12px;border-radius:8px;background:#3a1717;color:#ff9a9a;font-size:13px}
273
- form{margin:0}.field{margin-bottom:14px}label{display:block;margin-bottom:6px;font-size:12px}input{width:100%;padding:11px 13px;border:1px solid #333;border-radius:8px;background:#181818;color:#fff;font-size:15px}
274
- button{width:100%;margin-top:8px;padding:11px;border:1px solid #333;border-radius:8px;background:#1b1b1b;color:#fff;font-size:14px;cursor:pointer}.primary{border-color:#8c7a6b;background:#8c7a6b}.secondary{color:#bbb}.divider{display:flex;align-items:center;gap:10px;margin:18px 0;color:#777;font-size:12px}.divider:before,.divider:after{content:"";flex:1;height:1px;background:#2a2a2a}
275
- button:disabled{opacity:.55;cursor:not-allowed}.client{margin:20px 0;padding:14px;border:1px solid #2a2a2a;border-radius:9px;background:#181818}.client-name{font-weight:600;word-break:break-all}.scopes{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}.scope{padding:4px 7px;border:1px solid #333;border-radius:6px;color:#bbb;font-size:12px}.actions{display:flex;gap:8px}.actions button{margin:0}
308
+ html{--brand-primary:#8c7a6b;color-scheme:light}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:#f4f4f5;color:#171717;font-family:system-ui,sans-serif}
309
+ .card{width:100%;max-width:400px;padding:32px;border:1px solid #e4e4e7;border-radius:14px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.04)}.logo{width:38px;height:38px;margin:0 auto 20px;display:grid;place-items:center;border-radius:50%;background:#171717;color:#fff;font-weight:700}.logo.has-image{background:transparent;border-radius:8px}.logo img{width:38px;height:38px;object-fit:contain;display:block}
310
+ h1{margin:0 0 24px;text-align:center;font-size:22px;font-weight:650}.sub{margin:-12px 0 24px;text-align:center;color:#71717a;font-size:13px;line-height:1.5}.error{display:none;margin-bottom:16px;padding:10px 12px;border-radius:8px;background:#fef2f2;color:#b42318;font-size:13px}
311
+ form{margin:0}.field{margin-bottom:14px}label{display:block;margin-bottom:6px;font-size:12px;color:#52525b}input{width:100%;padding:11px 13px;border:1px solid #d4d4d8;border-radius:8px;background:#fff;color:#171717;font-size:15px}input:focus{outline:2px solid var(--brand-primary);outline-offset:1px;border-color:transparent}
312
+ button{width:100%;margin-top:8px;padding:11px;border:1px solid #d4d4d8;border-radius:8px;background:#fafafa;color:#171717;font-size:14px;font-weight:550;cursor:pointer}button:focus-visible{outline:2px solid var(--brand-primary);outline-offset:2px}.primary{border-color:var(--brand-primary);background:var(--brand-primary);color:#fff}.secondary{color:#52525b;background:transparent}.social{display:inline-flex;align-items:center;justify-content:center;gap:10px;background:#fff;color:#1f1f1f;border-color:#dadce0;font-weight:500}.social:hover{background:#f7f8f8}.social svg{width:18px;height:18px;flex:0 0 18px}
313
+ .divider{display:flex;align-items:center;gap:10px;margin:18px 0;color:#a1a1aa;font-size:12px}.divider:before,.divider:after{content:"";flex:1;height:1px;background:#e4e4e7}
314
+ button:disabled{opacity:.55;cursor:not-allowed}.client{margin:20px 0;padding:14px;border:1px solid #e4e4e7;border-radius:9px;background:#fafafa}.client-name{font-weight:600;word-break:break-all}.scopes{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}.scope{padding:4px 7px;border:1px solid #e4e4e7;border-radius:6px;color:#52525b;font-size:12px}.actions{display:flex;gap:8px}.actions button{margin:0}
276
315
  `;
277
- function page(title, body) {
278
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>${STYLES}</style></head><body><main class="card">${body}</main></body></html>`;
316
+ function page(title, body, options) {
317
+ const favicon = options.faviconUrl ? `<link rel="icon" href="${escapeHtml(options.faviconUrl)}">` : "";
318
+ const brandColor = options.primaryColor ? `html{--brand-primary:${options.primaryColor}}` : "";
319
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title>${favicon}<style>${STYLES}${brandColor}</style></head><body><main class="card">${body}</main></body></html>`;
320
+ }
321
+ function brandMark(options) {
322
+ var _a;
323
+ if (options.logoUrl) {
324
+ return `<div class="logo has-image"><img src="${escapeHtml(options.logoUrl)}" alt="${escapeHtml(options.appName)}"></div>`;
325
+ }
326
+ return `<div class="logo">${escapeHtml((_a = options.appName[0]) != null ? _a : "?")}</div>`;
327
+ }
328
+ var SOCIAL_ICONS = {
329
+ google: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>',
330
+ github: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.7 7.7 0 0 1 8 4.84c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>',
331
+ microsoft: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" aria-hidden="true"><path fill="#f35325" d="M1 1h10v10H1z"/><path fill="#81bc06" d="M12 1h10v10H12z"/><path fill="#05a6f0" d="M1 12h10v10H1z"/><path fill="#ffba08" d="M12 12h10v10H12z"/></svg>'
332
+ };
333
+ function socialButton(method) {
334
+ var _a;
335
+ const icon = (_a = SOCIAL_ICONS[method.providerId]) != null ? _a : "";
336
+ return `<button type="button" class="social" data-provider="${escapeHtml(method.providerId)}">${icon}<span>${escapeHtml(method.label)}</span></button>`;
279
337
  }
280
338
  function clearLegacyCookies(response, names) {
281
339
  for (const name of names != null ? names : []) {
@@ -289,19 +347,17 @@ function clearLegacyCookies(response, names) {
289
347
  }
290
348
  }
291
349
  function loginMarkup(options) {
292
- var _a, _b;
350
+ var _a;
293
351
  const emailMethod = options.loginMethods.find(
294
352
  (method) => method.type === "email-otp"
295
353
  );
296
354
  const socialMethods = options.loginMethods.filter(
297
355
  (method) => method.type === "social"
298
356
  );
299
- const email = emailMethod ? `<form id="email-form"><div class="field"><label for="email">Email</label><input id="email" type="email" required autocomplete="email"></div><button class="primary" type="submit">${escapeHtml((_a = emailMethod.label) != null ? _a : "Continue with email")}</button></form><form id="otp-form" hidden><div class="field"><label for="otp">Verification code</label><input id="otp" inputmode="numeric" required autocomplete="one-time-code"></div><button class="primary" type="submit">Sign in</button><button class="secondary" type="button" id="change-email">Use a different email</button></form>` : "";
300
- const social = socialMethods.map(
301
- (method) => `<button type="button" class="social" data-provider="${escapeHtml(method.providerId)}">${escapeHtml(method.label)}</button>`
302
- ).join("");
357
+ const email = emailMethod ? `<form id="email-form"><div class="field"><label for="email">Email address</label><input id="email" type="email" required autocomplete="email"></div><button class="primary" type="submit">${escapeHtml((_a = emailMethod.label) != null ? _a : "Continue with email")}</button></form><form id="otp-form" hidden><div class="field"><label for="otp">Verification code</label><input id="otp" inputmode="numeric" required autocomplete="one-time-code"></div><button class="primary" type="submit">Sign in</button><button class="secondary" type="button" id="change-email">Use a different email</button></form>` : "";
358
+ const social = socialMethods.map(socialButton).join("");
303
359
  const divider = email && social ? '<div class="divider">or</div>' : "";
304
- return `<div class="logo">${escapeHtml((_b = options.appName[0]) != null ? _b : "?")}</div><h1>Sign in to ${escapeHtml(options.appName)}</h1><p class="sub">Use one of the configured sign-in methods.</p><div class="error" id="error"></div>${email}${divider}${social}`;
360
+ return `${brandMark(options)}<h1>Sign in to ${escapeHtml(options.appName)}</h1><div class="error" id="error" role="alert"></div>${email}${divider}${social}`;
305
361
  }
306
362
  function loginScript(options, oauth, redirect) {
307
363
  const authBasePath = scriptLiteral(options.authBasePath);
@@ -310,7 +366,7 @@ function loginScript(options, oauth, redirect) {
310
366
  var authBasePath=${authBasePath};var oauth=${oauth};var oauthQuery=location.search.slice(1);var redirectTarget=${redirectTarget};var errorEl=document.getElementById("error");
311
367
  function showError(message){errorEl.textContent=message||"Sign-in failed. Please try again.";errorEl.style.display="block"}function clearError(){errorEl.style.display="none"}
312
368
  async function post(path,body){var response=await fetch(authBasePath+path,{method:"POST",headers:{"content-type":"application/json",accept:"application/json"},body:JSON.stringify(body)});var data={};try{data=await response.json()}catch{}if(!response.ok)throw new Error("Sign-in failed. Please try again.");return data}
313
- function follow(data){var target=oauth?(data.url||data.redirect_uri):redirectTarget;if(!target)throw new Error("Sign-in failed. Please try again.");location.assign(target)}
369
+ function follow(data){var target=data.url||data.redirect_uri||redirectTarget;if(!target)throw new Error("Sign-in failed. Please try again.");location.assign(target)}
314
370
  var emailForm=document.getElementById("email-form"),otpForm=document.getElementById("otp-form"),email="";
315
371
  if(emailForm){emailForm.addEventListener("submit",async function(event){event.preventDefault();clearError();email=emailForm.querySelector("input").value;try{await post("/email-otp/send-verification-otp",{email:email,type:"sign-in",...(oauth?{oauth_query:oauthQuery}:{})});emailForm.hidden=true;otpForm.hidden=false}catch(error){showError(error.message)}});otpForm.addEventListener("submit",async function(event){event.preventDefault();clearError();try{var data=await post("/sign-in/email-otp",{email:email,otp:document.getElementById("otp").value,name:email.split("@")[0],...(oauth?{oauth_query:oauthQuery}:{})});follow(data)}catch(error){showError(error.message)}});document.getElementById("change-email").addEventListener("click",function(){otpForm.hidden=true;emailForm.hidden=false;clearError()})}
316
372
  document.querySelectorAll("button.social").forEach(function(button){button.addEventListener("click",async function(){clearError();button.disabled=true;try{var body={provider:button.dataset.provider,...(oauth?{oauth_query:oauthQuery}:{callbackURL:redirectTarget,errorCallbackURL:location.href})};follow(await post("/sign-in/social",body))}catch(error){showError(error.message);button.disabled=false}})});
@@ -335,7 +391,8 @@ function createOAuthPagesRouter(rawOptions) {
335
391
  response.type("html").send(
336
392
  page(
337
393
  `Sign in to ${options.appName}`,
338
- loginMarkup(options) + loginScript(options, false, redirect)
394
+ loginMarkup(options) + loginScript(options, false, redirect),
395
+ options
339
396
  )
340
397
  );
341
398
  });
@@ -345,19 +402,19 @@ function createOAuthPagesRouter(rawOptions) {
345
402
  response.type("html").send(
346
403
  page(
347
404
  `Sign in to ${options.appName}`,
348
- loginMarkup(options) + loginScript(options, true)
405
+ loginMarkup(options) + loginScript(options, true),
406
+ options
349
407
  )
350
408
  );
351
409
  });
352
410
  router.get("/oauth/consent", (request, response) => {
353
- var _a;
354
411
  frameProtection(response);
355
412
  const clientId = typeof request.query.client_id === "string" ? request.query.client_id : "OAuth client";
356
413
  const scope = typeof request.query.scope === "string" ? request.query.scope : "";
357
414
  const scopes = scope.split(/\s+/).filter(Boolean);
358
415
  const badges = scopes.map((item) => `<span class="scope">${escapeHtml(item)}</span>`).join("");
359
- const body = `<div class="logo">${escapeHtml((_a = options.appName[0]) != null ? _a : "?")}</div><h1>Authorize ${escapeHtml(options.appName)} access</h1><p class="sub">Review the client and requested scopes.</p><div class="error" id="error"></div><div class="client"><div class="client-name">${escapeHtml(clientId)}</div>${badges ? `<div class="scopes">${badges}</div>` : ""}</div><div class="actions"><button class="primary" id="allow" type="button">Allow</button><button id="deny" type="button">Deny</button></div><script>(function(){var authBasePath=${scriptLiteral(options.authBasePath)},oauthQuery=location.search.slice(1),errorEl=document.getElementById("error"),buttons=[document.getElementById("allow"),document.getElementById("deny")];async function decide(accept){buttons.forEach(function(button){button.disabled=true});errorEl.style.display="none";try{var response=await fetch(authBasePath+"/oauth2/consent",{method:"POST",headers:{"content-type":"application/json",accept:"application/json"},body:JSON.stringify({accept:accept,oauth_query:oauthQuery})});var data={};try{data=await response.json()}catch{}var target=data.url||data.redirect_uri;if(!response.ok||!target)throw new Error();location.assign(target)}catch{errorEl.textContent="Could not complete authorization.";errorEl.style.display="block";buttons.forEach(function(button){button.disabled=false})}}buttons[0].addEventListener("click",function(){decide(true)});buttons[1].addEventListener("click",function(){decide(false)})})();</script>`;
360
- response.type("html").send(page(`Authorize ${options.appName} access`, body));
416
+ const body = `${brandMark(options)}<h1>Authorize ${escapeHtml(options.appName)} access</h1><p class="sub">Review the client and requested scopes.</p><div class="error" id="error"></div><div class="client"><div class="client-name">${escapeHtml(clientId)}</div>${badges ? `<div class="scopes">${badges}</div>` : ""}</div><div class="actions"><button class="primary" id="allow" type="button">Allow</button><button id="deny" type="button">Deny</button></div><script>(function(){var authBasePath=${scriptLiteral(options.authBasePath)},oauthQuery=location.search.slice(1),errorEl=document.getElementById("error"),buttons=[document.getElementById("allow"),document.getElementById("deny")];async function decide(accept){buttons.forEach(function(button){button.disabled=true});errorEl.style.display="none";try{var response=await fetch(authBasePath+"/oauth2/consent",{method:"POST",headers:{"content-type":"application/json",accept:"application/json"},body:JSON.stringify({accept:accept,oauth_query:oauthQuery})});var data={};try{data=await response.json()}catch{}var target=data.url||data.redirect_uri;if(!response.ok||!target)throw new Error();location.assign(target)}catch{errorEl.textContent="Could not complete authorization.";errorEl.style.display="block";buttons.forEach(function(button){button.disabled=false})}}buttons[0].addEventListener("click",function(){decide(true)});buttons[1].addEventListener("click",function(){decide(false)})})();</script>`;
417
+ response.type("html").send(page(`Authorize ${options.appName} access`, body, options));
361
418
  });
362
419
  return router;
363
420
  }
package/dist/express.mjs CHANGED
@@ -106,6 +106,41 @@ function absoluteOrigin(value, field) {
106
106
  }
107
107
  return url.origin;
108
108
  }
109
+ var HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
110
+ var OKLCH_COLOR = /^oklch\([0-9.%\s/,+-]+\)$/i;
111
+ function brandingColor(value, field) {
112
+ if (value === void 0) return void 0;
113
+ const color = value.trim();
114
+ if (HEX_COLOR.test(color) || OKLCH_COLOR.test(color)) return color;
115
+ throw new Error(`${field} must be a hex or oklch() color`);
116
+ }
117
+ function brandingUrl(value, field) {
118
+ if (value === void 0) return void 0;
119
+ const raw = value.trim();
120
+ if (!raw) throw new Error(`${field} must be a URL`);
121
+ if (raw.startsWith("/") && !raw.startsWith("//")) {
122
+ if (/\s/.test(raw) || raw.includes("\\") || raw.includes("<")) {
123
+ throw new Error(`${field} must be a safe URL`);
124
+ }
125
+ return raw;
126
+ }
127
+ let url;
128
+ try {
129
+ url = new URL(raw);
130
+ } catch (e) {
131
+ throw new Error(`${field} must be an absolute URL`);
132
+ }
133
+ if (!["http:", "https:"].includes(url.protocol)) {
134
+ throw new Error(`${field} must use http or https`);
135
+ }
136
+ if (process.env.NODE_ENV === "production" && url.protocol !== "https:") {
137
+ throw new Error(`${field} must use https in production`);
138
+ }
139
+ if (url.username || url.password) {
140
+ throw new Error(`${field} must not contain credentials`);
141
+ }
142
+ return url.toString();
143
+ }
109
144
  function normalizeOptions(options) {
110
145
  var _a;
111
146
  if (!options.appName.trim()) throw new Error("appName is required");
@@ -160,19 +195,42 @@ function normalizeOptions(options) {
160
195
  appName: options.appName.trim(),
161
196
  authBasePath,
162
197
  allowedRedirectOrigins,
163
- defaultRedirectUrl: defaultRedirect.toString()
198
+ defaultRedirectUrl: defaultRedirect.toString(),
199
+ logoUrl: brandingUrl(options.logoUrl, "logoUrl"),
200
+ faviconUrl: brandingUrl(options.faviconUrl, "faviconUrl"),
201
+ primaryColor: brandingColor(options.primaryColor, "primaryColor")
164
202
  });
165
203
  }
166
204
  var STYLES = `
167
- *{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:#0b0b0b;color:#fff;font-family:system-ui,sans-serif}
168
- .card{width:100%;max-width:400px;padding:32px;border:1px solid #292929;border-radius:14px;background:#111}.logo{width:38px;height:38px;margin:0 auto 20px;display:grid;place-items:center;border-radius:50%;background:#fff;color:#111;font-weight:700}
169
- h1{margin:0 0 8px;text-align:center;font-size:22px}.sub{margin:0 0 24px;text-align:center;color:#aaa;font-size:13px;line-height:1.5}.error{display:none;margin-bottom:16px;padding:10px 12px;border-radius:8px;background:#3a1717;color:#ff9a9a;font-size:13px}
170
- form{margin:0}.field{margin-bottom:14px}label{display:block;margin-bottom:6px;font-size:12px}input{width:100%;padding:11px 13px;border:1px solid #333;border-radius:8px;background:#181818;color:#fff;font-size:15px}
171
- button{width:100%;margin-top:8px;padding:11px;border:1px solid #333;border-radius:8px;background:#1b1b1b;color:#fff;font-size:14px;cursor:pointer}.primary{border-color:#8c7a6b;background:#8c7a6b}.secondary{color:#bbb}.divider{display:flex;align-items:center;gap:10px;margin:18px 0;color:#777;font-size:12px}.divider:before,.divider:after{content:"";flex:1;height:1px;background:#2a2a2a}
172
- button:disabled{opacity:.55;cursor:not-allowed}.client{margin:20px 0;padding:14px;border:1px solid #2a2a2a;border-radius:9px;background:#181818}.client-name{font-weight:600;word-break:break-all}.scopes{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}.scope{padding:4px 7px;border:1px solid #333;border-radius:6px;color:#bbb;font-size:12px}.actions{display:flex;gap:8px}.actions button{margin:0}
205
+ html{--brand-primary:#8c7a6b;color-scheme:light}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:#f4f4f5;color:#171717;font-family:system-ui,sans-serif}
206
+ .card{width:100%;max-width:400px;padding:32px;border:1px solid #e4e4e7;border-radius:14px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.04)}.logo{width:38px;height:38px;margin:0 auto 20px;display:grid;place-items:center;border-radius:50%;background:#171717;color:#fff;font-weight:700}.logo.has-image{background:transparent;border-radius:8px}.logo img{width:38px;height:38px;object-fit:contain;display:block}
207
+ h1{margin:0 0 24px;text-align:center;font-size:22px;font-weight:650}.sub{margin:-12px 0 24px;text-align:center;color:#71717a;font-size:13px;line-height:1.5}.error{display:none;margin-bottom:16px;padding:10px 12px;border-radius:8px;background:#fef2f2;color:#b42318;font-size:13px}
208
+ form{margin:0}.field{margin-bottom:14px}label{display:block;margin-bottom:6px;font-size:12px;color:#52525b}input{width:100%;padding:11px 13px;border:1px solid #d4d4d8;border-radius:8px;background:#fff;color:#171717;font-size:15px}input:focus{outline:2px solid var(--brand-primary);outline-offset:1px;border-color:transparent}
209
+ button{width:100%;margin-top:8px;padding:11px;border:1px solid #d4d4d8;border-radius:8px;background:#fafafa;color:#171717;font-size:14px;font-weight:550;cursor:pointer}button:focus-visible{outline:2px solid var(--brand-primary);outline-offset:2px}.primary{border-color:var(--brand-primary);background:var(--brand-primary);color:#fff}.secondary{color:#52525b;background:transparent}.social{display:inline-flex;align-items:center;justify-content:center;gap:10px;background:#fff;color:#1f1f1f;border-color:#dadce0;font-weight:500}.social:hover{background:#f7f8f8}.social svg{width:18px;height:18px;flex:0 0 18px}
210
+ .divider{display:flex;align-items:center;gap:10px;margin:18px 0;color:#a1a1aa;font-size:12px}.divider:before,.divider:after{content:"";flex:1;height:1px;background:#e4e4e7}
211
+ button:disabled{opacity:.55;cursor:not-allowed}.client{margin:20px 0;padding:14px;border:1px solid #e4e4e7;border-radius:9px;background:#fafafa}.client-name{font-weight:600;word-break:break-all}.scopes{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}.scope{padding:4px 7px;border:1px solid #e4e4e7;border-radius:6px;color:#52525b;font-size:12px}.actions{display:flex;gap:8px}.actions button{margin:0}
173
212
  `;
174
- function page(title, body) {
175
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>${STYLES}</style></head><body><main class="card">${body}</main></body></html>`;
213
+ function page(title, body, options) {
214
+ const favicon = options.faviconUrl ? `<link rel="icon" href="${escapeHtml(options.faviconUrl)}">` : "";
215
+ const brandColor = options.primaryColor ? `html{--brand-primary:${options.primaryColor}}` : "";
216
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title>${favicon}<style>${STYLES}${brandColor}</style></head><body><main class="card">${body}</main></body></html>`;
217
+ }
218
+ function brandMark(options) {
219
+ var _a;
220
+ if (options.logoUrl) {
221
+ return `<div class="logo has-image"><img src="${escapeHtml(options.logoUrl)}" alt="${escapeHtml(options.appName)}"></div>`;
222
+ }
223
+ return `<div class="logo">${escapeHtml((_a = options.appName[0]) != null ? _a : "?")}</div>`;
224
+ }
225
+ var SOCIAL_ICONS = {
226
+ google: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>',
227
+ github: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.7 7.7 0 0 1 8 4.84c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>',
228
+ microsoft: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" aria-hidden="true"><path fill="#f35325" d="M1 1h10v10H1z"/><path fill="#81bc06" d="M12 1h10v10H12z"/><path fill="#05a6f0" d="M1 12h10v10H1z"/><path fill="#ffba08" d="M12 12h10v10H12z"/></svg>'
229
+ };
230
+ function socialButton(method) {
231
+ var _a;
232
+ const icon = (_a = SOCIAL_ICONS[method.providerId]) != null ? _a : "";
233
+ return `<button type="button" class="social" data-provider="${escapeHtml(method.providerId)}">${icon}<span>${escapeHtml(method.label)}</span></button>`;
176
234
  }
177
235
  function clearLegacyCookies(response, names) {
178
236
  for (const name of names != null ? names : []) {
@@ -186,19 +244,17 @@ function clearLegacyCookies(response, names) {
186
244
  }
187
245
  }
188
246
  function loginMarkup(options) {
189
- var _a, _b;
247
+ var _a;
190
248
  const emailMethod = options.loginMethods.find(
191
249
  (method) => method.type === "email-otp"
192
250
  );
193
251
  const socialMethods = options.loginMethods.filter(
194
252
  (method) => method.type === "social"
195
253
  );
196
- const email = emailMethod ? `<form id="email-form"><div class="field"><label for="email">Email</label><input id="email" type="email" required autocomplete="email"></div><button class="primary" type="submit">${escapeHtml((_a = emailMethod.label) != null ? _a : "Continue with email")}</button></form><form id="otp-form" hidden><div class="field"><label for="otp">Verification code</label><input id="otp" inputmode="numeric" required autocomplete="one-time-code"></div><button class="primary" type="submit">Sign in</button><button class="secondary" type="button" id="change-email">Use a different email</button></form>` : "";
197
- const social = socialMethods.map(
198
- (method) => `<button type="button" class="social" data-provider="${escapeHtml(method.providerId)}">${escapeHtml(method.label)}</button>`
199
- ).join("");
254
+ const email = emailMethod ? `<form id="email-form"><div class="field"><label for="email">Email address</label><input id="email" type="email" required autocomplete="email"></div><button class="primary" type="submit">${escapeHtml((_a = emailMethod.label) != null ? _a : "Continue with email")}</button></form><form id="otp-form" hidden><div class="field"><label for="otp">Verification code</label><input id="otp" inputmode="numeric" required autocomplete="one-time-code"></div><button class="primary" type="submit">Sign in</button><button class="secondary" type="button" id="change-email">Use a different email</button></form>` : "";
255
+ const social = socialMethods.map(socialButton).join("");
200
256
  const divider = email && social ? '<div class="divider">or</div>' : "";
201
- return `<div class="logo">${escapeHtml((_b = options.appName[0]) != null ? _b : "?")}</div><h1>Sign in to ${escapeHtml(options.appName)}</h1><p class="sub">Use one of the configured sign-in methods.</p><div class="error" id="error"></div>${email}${divider}${social}`;
257
+ return `${brandMark(options)}<h1>Sign in to ${escapeHtml(options.appName)}</h1><div class="error" id="error" role="alert"></div>${email}${divider}${social}`;
202
258
  }
203
259
  function loginScript(options, oauth, redirect) {
204
260
  const authBasePath = scriptLiteral(options.authBasePath);
@@ -207,7 +263,7 @@ function loginScript(options, oauth, redirect) {
207
263
  var authBasePath=${authBasePath};var oauth=${oauth};var oauthQuery=location.search.slice(1);var redirectTarget=${redirectTarget};var errorEl=document.getElementById("error");
208
264
  function showError(message){errorEl.textContent=message||"Sign-in failed. Please try again.";errorEl.style.display="block"}function clearError(){errorEl.style.display="none"}
209
265
  async function post(path,body){var response=await fetch(authBasePath+path,{method:"POST",headers:{"content-type":"application/json",accept:"application/json"},body:JSON.stringify(body)});var data={};try{data=await response.json()}catch{}if(!response.ok)throw new Error("Sign-in failed. Please try again.");return data}
210
- function follow(data){var target=oauth?(data.url||data.redirect_uri):redirectTarget;if(!target)throw new Error("Sign-in failed. Please try again.");location.assign(target)}
266
+ function follow(data){var target=data.url||data.redirect_uri||redirectTarget;if(!target)throw new Error("Sign-in failed. Please try again.");location.assign(target)}
211
267
  var emailForm=document.getElementById("email-form"),otpForm=document.getElementById("otp-form"),email="";
212
268
  if(emailForm){emailForm.addEventListener("submit",async function(event){event.preventDefault();clearError();email=emailForm.querySelector("input").value;try{await post("/email-otp/send-verification-otp",{email:email,type:"sign-in",...(oauth?{oauth_query:oauthQuery}:{})});emailForm.hidden=true;otpForm.hidden=false}catch(error){showError(error.message)}});otpForm.addEventListener("submit",async function(event){event.preventDefault();clearError();try{var data=await post("/sign-in/email-otp",{email:email,otp:document.getElementById("otp").value,name:email.split("@")[0],...(oauth?{oauth_query:oauthQuery}:{})});follow(data)}catch(error){showError(error.message)}});document.getElementById("change-email").addEventListener("click",function(){otpForm.hidden=true;emailForm.hidden=false;clearError()})}
213
269
  document.querySelectorAll("button.social").forEach(function(button){button.addEventListener("click",async function(){clearError();button.disabled=true;try{var body={provider:button.dataset.provider,...(oauth?{oauth_query:oauthQuery}:{callbackURL:redirectTarget,errorCallbackURL:location.href})};follow(await post("/sign-in/social",body))}catch(error){showError(error.message);button.disabled=false}})});
@@ -232,7 +288,8 @@ function createOAuthPagesRouter(rawOptions) {
232
288
  response.type("html").send(
233
289
  page(
234
290
  `Sign in to ${options.appName}`,
235
- loginMarkup(options) + loginScript(options, false, redirect)
291
+ loginMarkup(options) + loginScript(options, false, redirect),
292
+ options
236
293
  )
237
294
  );
238
295
  });
@@ -242,19 +299,19 @@ function createOAuthPagesRouter(rawOptions) {
242
299
  response.type("html").send(
243
300
  page(
244
301
  `Sign in to ${options.appName}`,
245
- loginMarkup(options) + loginScript(options, true)
302
+ loginMarkup(options) + loginScript(options, true),
303
+ options
246
304
  )
247
305
  );
248
306
  });
249
307
  router.get("/oauth/consent", (request, response) => {
250
- var _a;
251
308
  frameProtection(response);
252
309
  const clientId = typeof request.query.client_id === "string" ? request.query.client_id : "OAuth client";
253
310
  const scope = typeof request.query.scope === "string" ? request.query.scope : "";
254
311
  const scopes = scope.split(/\s+/).filter(Boolean);
255
312
  const badges = scopes.map((item) => `<span class="scope">${escapeHtml(item)}</span>`).join("");
256
- const body = `<div class="logo">${escapeHtml((_a = options.appName[0]) != null ? _a : "?")}</div><h1>Authorize ${escapeHtml(options.appName)} access</h1><p class="sub">Review the client and requested scopes.</p><div class="error" id="error"></div><div class="client"><div class="client-name">${escapeHtml(clientId)}</div>${badges ? `<div class="scopes">${badges}</div>` : ""}</div><div class="actions"><button class="primary" id="allow" type="button">Allow</button><button id="deny" type="button">Deny</button></div><script>(function(){var authBasePath=${scriptLiteral(options.authBasePath)},oauthQuery=location.search.slice(1),errorEl=document.getElementById("error"),buttons=[document.getElementById("allow"),document.getElementById("deny")];async function decide(accept){buttons.forEach(function(button){button.disabled=true});errorEl.style.display="none";try{var response=await fetch(authBasePath+"/oauth2/consent",{method:"POST",headers:{"content-type":"application/json",accept:"application/json"},body:JSON.stringify({accept:accept,oauth_query:oauthQuery})});var data={};try{data=await response.json()}catch{}var target=data.url||data.redirect_uri;if(!response.ok||!target)throw new Error();location.assign(target)}catch{errorEl.textContent="Could not complete authorization.";errorEl.style.display="block";buttons.forEach(function(button){button.disabled=false})}}buttons[0].addEventListener("click",function(){decide(true)});buttons[1].addEventListener("click",function(){decide(false)})})();</script>`;
257
- response.type("html").send(page(`Authorize ${options.appName} access`, body));
313
+ const body = `${brandMark(options)}<h1>Authorize ${escapeHtml(options.appName)} access</h1><p class="sub">Review the client and requested scopes.</p><div class="error" id="error"></div><div class="client"><div class="client-name">${escapeHtml(clientId)}</div>${badges ? `<div class="scopes">${badges}</div>` : ""}</div><div class="actions"><button class="primary" id="allow" type="button">Allow</button><button id="deny" type="button">Deny</button></div><script>(function(){var authBasePath=${scriptLiteral(options.authBasePath)},oauthQuery=location.search.slice(1),errorEl=document.getElementById("error"),buttons=[document.getElementById("allow"),document.getElementById("deny")];async function decide(accept){buttons.forEach(function(button){button.disabled=true});errorEl.style.display="none";try{var response=await fetch(authBasePath+"/oauth2/consent",{method:"POST",headers:{"content-type":"application/json",accept:"application/json"},body:JSON.stringify({accept:accept,oauth_query:oauthQuery})});var data={};try{data=await response.json()}catch{}var target=data.url||data.redirect_uri;if(!response.ok||!target)throw new Error();location.assign(target)}catch{errorEl.textContent="Could not complete authorization.";errorEl.style.display="block";buttons.forEach(function(button){button.disabled=false})}}buttons[0].addEventListener("click",function(){decide(true)});buttons[1].addEventListener("click",function(){decide(false)})})();</script>`;
314
+ response.type("html").send(page(`Authorize ${options.appName} access`, body, options));
258
315
  });
259
316
  return router;
260
317
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codelitdev/oauth-server-kit",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.1",
4
4
  "description": "Lean Better Auth OAuth authentication framework for web, mobile, REST, and MCP surfaces.",
5
5
  "author": "CodeLit",
6
6
  "license": "MIT",
@@ -66,17 +66,17 @@
66
66
  "format:check": "prettier --check src test docs *.md *.json",
67
67
  "quality": "pnpm format:check && pnpm lint && pnpm check-types",
68
68
  "prepare": "husky",
69
- "release:alpha": "pnpm quality && pnpm test:coverage && pnpm build && npm publish --tag alpha"
69
+ "release:alpha": "pnpm quality && pnpm test:coverage && pnpm build && pnpm publish --tag alpha --access public --no-git-checks"
70
+ },
71
+ "publishConfig": {
72
+ "access": "public",
73
+ "tag": "alpha"
70
74
  },
71
75
  "peerDependencies": {
72
76
  "@better-auth/oauth-provider": "^1.6.23",
73
77
  "better-auth": "^1.6.23",
74
78
  "express": "^4.21.2"
75
79
  },
76
- "publishConfig": {
77
- "access": "public",
78
- "tag": "alpha"
79
- },
80
80
  "devDependencies": {
81
81
  "@better-auth/oauth-provider": "^1.6.23",
82
82
  "@types/express": "^4.17.20",
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 CodeLit
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.