@codelitdev/oauth-server-kit 0.4.2 → 0.6.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.
@@ -24,6 +24,13 @@ type HostedLoginMethod = {
24
24
  providerId: string;
25
25
  label: string;
26
26
  };
27
+ /** A bot check the email-OTP form completes before asking for a code. The
28
+ * page sends its token in the `x-captcha-response` header, which Better
29
+ * Auth's `captcha` plugin verifies with the provider's secret key. */
30
+ type HostedLoginCaptcha = {
31
+ provider: "cloudflare-turnstile";
32
+ siteKey: string;
33
+ };
27
34
  interface CreateOAuthPagesRouterOptions {
28
35
  appName: string;
29
36
  authBasePath: string;
@@ -34,7 +41,8 @@ interface CreateOAuthPagesRouterOptions {
34
41
  logoUrl?: string;
35
42
  faviconUrl?: string;
36
43
  primaryColor?: string;
44
+ captcha?: HostedLoginCaptcha;
37
45
  }
38
46
  declare function createOAuthPagesRouter(rawOptions: CreateOAuthPagesRouterOptions): Router;
39
47
 
40
- export { type CreateOAuthBearerMiddlewareOptions, type CreateOAuthPagesRouterOptions, type HostedLoginMethod, createOAuthBearerMiddleware, createOAuthPagesRouter };
48
+ export { type CreateOAuthBearerMiddlewareOptions, type CreateOAuthPagesRouterOptions, type HostedLoginCaptcha, type HostedLoginMethod, createOAuthBearerMiddleware, createOAuthPagesRouter };
package/dist/express.d.ts CHANGED
@@ -24,6 +24,13 @@ type HostedLoginMethod = {
24
24
  providerId: string;
25
25
  label: string;
26
26
  };
27
+ /** A bot check the email-OTP form completes before asking for a code. The
28
+ * page sends its token in the `x-captcha-response` header, which Better
29
+ * Auth's `captcha` plugin verifies with the provider's secret key. */
30
+ type HostedLoginCaptcha = {
31
+ provider: "cloudflare-turnstile";
32
+ siteKey: string;
33
+ };
27
34
  interface CreateOAuthPagesRouterOptions {
28
35
  appName: string;
29
36
  authBasePath: string;
@@ -34,7 +41,8 @@ interface CreateOAuthPagesRouterOptions {
34
41
  logoUrl?: string;
35
42
  faviconUrl?: string;
36
43
  primaryColor?: string;
44
+ captcha?: HostedLoginCaptcha;
37
45
  }
38
46
  declare function createOAuthPagesRouter(rawOptions: CreateOAuthPagesRouterOptions): Router;
39
47
 
40
- export { type CreateOAuthBearerMiddlewareOptions, type CreateOAuthPagesRouterOptions, type HostedLoginMethod, createOAuthBearerMiddleware, createOAuthPagesRouter };
48
+ export { type CreateOAuthBearerMiddlewareOptions, type CreateOAuthPagesRouterOptions, type HostedLoginCaptcha, type HostedLoginMethod, createOAuthBearerMiddleware, createOAuthPagesRouter };
package/dist/express.js CHANGED
@@ -294,6 +294,14 @@ function normalizeOptions(options) {
294
294
  }
295
295
  }
296
296
  }
297
+ if (options.captcha) {
298
+ if (options.captcha.provider !== "cloudflare-turnstile") {
299
+ throw new Error("captcha provider must be cloudflare-turnstile");
300
+ }
301
+ if (!/^[A-Za-z0-9_-]+$/.test(options.captcha.siteKey)) {
302
+ throw new Error("captcha siteKey must be a Turnstile site key");
303
+ }
304
+ }
297
305
  for (const name of (_a = options.legacyHostOnlySessionCookieNames) != null ? _a : []) {
298
306
  if (!/^[A-Za-z0-9._-]+$/.test(name)) {
299
307
  throw new Error("legacy cookie names contain invalid characters");
@@ -315,7 +323,7 @@ html{--brand-primary:#8c7a6b;color-scheme:light}*{box-sizing:border-box}body{mar
315
323
  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}
316
324
  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}
317
325
  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}
318
- .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}
326
+ .captcha{margin-bottom:6px;min-height:65px}.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}
319
327
  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}
320
328
  `;
321
329
  function page(title, body, options) {
@@ -359,7 +367,8 @@ function loginMarkup(options) {
359
367
  const socialMethods = options.loginMethods.filter(
360
368
  (method) => method.type === "social"
361
369
  );
362
- 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>` : "";
370
+ const captcha = emailMethod && options.captcha ? `<div class="captcha"><div class="cf-turnstile" data-sitekey="${escapeHtml(options.captcha.siteKey)}" data-theme="light" data-size="flexible"></div></div><script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>` : "";
371
+ 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>${captcha}<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>` : "";
363
372
  const social = socialMethods.map(socialButton).join("");
364
373
  const divider = email && social ? '<div class="divider">or</div>' : "";
365
374
  return `${brandMark(options)}<h1>Sign in to ${escapeHtml(options.appName)}</h1><div class="error" id="error" role="alert"></div>${email}${divider}${social}`;
@@ -368,12 +377,12 @@ function loginScript(options, oauth, redirect) {
368
377
  const authBasePath = scriptLiteral(options.authBasePath);
369
378
  const redirectTarget = scriptLiteral(redirect != null ? redirect : "");
370
379
  return `<script>(function(){
371
- var authBasePath=${authBasePath};var oauth=${oauth};var oauthQuery=location.search.slice(1);var redirectTarget=${redirectTarget};var errorEl=document.getElementById("error");
380
+ var authBasePath=${authBasePath};var oauth=${oauth};var captcha=${Boolean(options.captcha)};var oauthQuery=location.search.slice(1);var redirectTarget=${redirectTarget};var errorEl=document.getElementById("error");
372
381
  function showError(message){errorEl.textContent=message||"Sign-in failed. Please try again.";errorEl.style.display="block"}function clearError(){errorEl.style.display="none"}
373
- 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(typeof data.message==="string"&&data.message?data.message:"Sign-in failed. Please try again.");return data}
382
+ async function post(path,body,headers){var response=await fetch(authBasePath+path,{method:"POST",headers:{"content-type":"application/json",accept:"application/json",...headers},body:JSON.stringify(body)});var data={};try{data=await response.json()}catch{}if(!response.ok)throw new Error(typeof data.message==="string"&&data.message?data.message:"Sign-in failed. Please try again.");return data}
374
383
  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)}
375
384
  var emailForm=document.getElementById("email-form"),otpForm=document.getElementById("otp-form"),email="";
376
- 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()})}
385
+ if(emailForm){emailForm.addEventListener("submit",async function(event){event.preventDefault();clearError();email=document.getElementById("email").value;var headers={};if(captcha){var token=emailForm.querySelector('input[name="cf-turnstile-response"]');if(!token||!token.value){showError("Complete the security check, then try again.");return}headers["x-captcha-response"]=token.value}try{await post("/email-otp/send-verification-otp",{email:email,type:"sign-in",...(oauth?{oauth_query:oauthQuery}:{})},headers);emailForm.hidden=true;otpForm.hidden=false}catch(error){showError(error.message)}finally{if(captcha&&typeof turnstile!=="undefined")turnstile.reset()}});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()})}
377
386
  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}})});
378
387
  })();</script>`;
379
388
  }
package/dist/express.mjs CHANGED
@@ -181,6 +181,14 @@ function normalizeOptions(options) {
181
181
  }
182
182
  }
183
183
  }
184
+ if (options.captcha) {
185
+ if (options.captcha.provider !== "cloudflare-turnstile") {
186
+ throw new Error("captcha provider must be cloudflare-turnstile");
187
+ }
188
+ if (!/^[A-Za-z0-9_-]+$/.test(options.captcha.siteKey)) {
189
+ throw new Error("captcha siteKey must be a Turnstile site key");
190
+ }
191
+ }
184
192
  for (const name of (_a = options.legacyHostOnlySessionCookieNames) != null ? _a : []) {
185
193
  if (!/^[A-Za-z0-9._-]+$/.test(name)) {
186
194
  throw new Error("legacy cookie names contain invalid characters");
@@ -202,7 +210,7 @@ html{--brand-primary:#8c7a6b;color-scheme:light}*{box-sizing:border-box}body{mar
202
210
  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}
203
211
  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}
204
212
  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}
205
- .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}
213
+ .captcha{margin-bottom:6px;min-height:65px}.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}
206
214
  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}
207
215
  `;
208
216
  function page(title, body, options) {
@@ -246,7 +254,8 @@ function loginMarkup(options) {
246
254
  const socialMethods = options.loginMethods.filter(
247
255
  (method) => method.type === "social"
248
256
  );
249
- 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>` : "";
257
+ const captcha = emailMethod && options.captcha ? `<div class="captcha"><div class="cf-turnstile" data-sitekey="${escapeHtml(options.captcha.siteKey)}" data-theme="light" data-size="flexible"></div></div><script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>` : "";
258
+ 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>${captcha}<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>` : "";
250
259
  const social = socialMethods.map(socialButton).join("");
251
260
  const divider = email && social ? '<div class="divider">or</div>' : "";
252
261
  return `${brandMark(options)}<h1>Sign in to ${escapeHtml(options.appName)}</h1><div class="error" id="error" role="alert"></div>${email}${divider}${social}`;
@@ -255,12 +264,12 @@ function loginScript(options, oauth, redirect) {
255
264
  const authBasePath = scriptLiteral(options.authBasePath);
256
265
  const redirectTarget = scriptLiteral(redirect != null ? redirect : "");
257
266
  return `<script>(function(){
258
- var authBasePath=${authBasePath};var oauth=${oauth};var oauthQuery=location.search.slice(1);var redirectTarget=${redirectTarget};var errorEl=document.getElementById("error");
267
+ var authBasePath=${authBasePath};var oauth=${oauth};var captcha=${Boolean(options.captcha)};var oauthQuery=location.search.slice(1);var redirectTarget=${redirectTarget};var errorEl=document.getElementById("error");
259
268
  function showError(message){errorEl.textContent=message||"Sign-in failed. Please try again.";errorEl.style.display="block"}function clearError(){errorEl.style.display="none"}
260
- 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(typeof data.message==="string"&&data.message?data.message:"Sign-in failed. Please try again.");return data}
269
+ async function post(path,body,headers){var response=await fetch(authBasePath+path,{method:"POST",headers:{"content-type":"application/json",accept:"application/json",...headers},body:JSON.stringify(body)});var data={};try{data=await response.json()}catch{}if(!response.ok)throw new Error(typeof data.message==="string"&&data.message?data.message:"Sign-in failed. Please try again.");return data}
261
270
  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)}
262
271
  var emailForm=document.getElementById("email-form"),otpForm=document.getElementById("otp-form"),email="";
263
- 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()})}
272
+ if(emailForm){emailForm.addEventListener("submit",async function(event){event.preventDefault();clearError();email=document.getElementById("email").value;var headers={};if(captcha){var token=emailForm.querySelector('input[name="cf-turnstile-response"]');if(!token||!token.value){showError("Complete the security check, then try again.");return}headers["x-captcha-response"]=token.value}try{await post("/email-otp/send-verification-otp",{email:email,type:"sign-in",...(oauth?{oauth_query:oauthQuery}:{})},headers);emailForm.hidden=true;otpForm.hidden=false}catch(error){showError(error.message)}finally{if(captcha&&typeof turnstile!=="undefined")turnstile.reset()}});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()})}
264
273
  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}})});
265
274
  })();</script>`;
266
275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codelitdev/oauth-server-kit",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Lean Better Auth OAuth authentication framework for web, mobile, REST, and MCP surfaces.",
5
5
  "author": "CodeLit",
6
6
  "license": "MIT",