@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 +0 -22
- package/dist/express.d.mts +3 -0
- package/dist/express.d.ts +3 -0
- package/dist/express.js +78 -21
- package/dist/express.mjs +78 -21
- package/package.json +6 -6
- package/LICENSE +0 -21
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.
|
package/dist/express.d.mts
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.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:#
|
|
271
|
-
.card{width:100%;max-width:400px;padding:32px;border:1px solid #
|
|
272
|
-
h1{margin:0 0
|
|
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 #
|
|
274
|
-
button{width:100%;margin-top:8px;padding:11px;border:1px solid #
|
|
275
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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=
|
|
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 =
|
|
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:#
|
|
168
|
-
.card{width:100%;max-width:400px;padding:32px;border:1px solid #
|
|
169
|
-
h1{margin:0 0
|
|
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 #
|
|
171
|
-
button{width:100%;margin-top:8px;padding:11px;border:1px solid #
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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=
|
|
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 =
|
|
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.
|
|
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 &&
|
|
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.
|