Buckets:
ktongue/docker_container / .cache /opencode /node_modules /@openauthjs /openauth /dist /esm /issuer.js
| // src/issuer.ts | |
| import { Hono } from "hono/tiny"; | |
| import { handle as awsHandle } from "hono/aws-lambda"; | |
| import { deleteCookie, getCookie, setCookie } from "hono/cookie"; | |
| import { | |
| MissingParameterError, | |
| OauthError, | |
| UnauthorizedClientError, | |
| UnknownStateError | |
| } from "./error.js"; | |
| import { compactDecrypt, CompactEncrypt, SignJWT } from "jose"; | |
| import { Storage } from "./storage/storage.js"; | |
| import { encryptionKeys, legacySigningKeys, signingKeys } from "./keys.js"; | |
| import { validatePKCE } from "./pkce.js"; | |
| import { Select } from "./ui/select.js"; | |
| import { setTheme } from "./ui/theme.js"; | |
| import { getRelativeUrl, isDomainMatch, lazy } from "./util.js"; | |
| import { DynamoStorage } from "./storage/dynamo.js"; | |
| import { MemoryStorage } from "./storage/memory.js"; | |
| import { cors } from "hono/cors"; | |
| import { logger } from "hono/logger"; | |
| var aws = awsHandle; | |
| function issuer(input) { | |
| const error = input.error ?? function(err) { | |
| return new Response(err.message, { | |
| status: 400, | |
| headers: { | |
| "Content-Type": "text/plain" | |
| } | |
| }); | |
| }; | |
| const ttlAccess = input.ttl?.access ?? 60 * 60 * 24 * 30; | |
| const ttlRefresh = input.ttl?.refresh ?? 60 * 60 * 24 * 365; | |
| const ttlRefreshReuse = input.ttl?.reuse ?? 60; | |
| const ttlRefreshRetention = input.ttl?.retention ?? 0; | |
| if (input.theme) { | |
| setTheme(input.theme); | |
| } | |
| const select = lazy(() => input.select ?? Select()); | |
| const allow = lazy(() => input.allow ?? (async (input2, req) => { | |
| const redir = new URL(input2.redirectURI).hostname; | |
| if (redir === "localhost" || redir === "127.0.0.1") { | |
| return true; | |
| } | |
| const forwarded = req.headers.get("x-forwarded-host"); | |
| const host = forwarded ? new URL(`https://${forwarded}`).hostname : new URL(req.url).hostname; | |
| return isDomainMatch(redir, host); | |
| })); | |
| let storage = input.storage; | |
| if (process.env.OPENAUTH_STORAGE) { | |
| const parsed = JSON.parse(process.env.OPENAUTH_STORAGE); | |
| if (parsed.type === "dynamo") | |
| storage = DynamoStorage(parsed.options); | |
| if (parsed.type === "memory") | |
| storage = MemoryStorage(); | |
| if (parsed.type === "cloudflare") | |
| throw new Error("Cloudflare storage cannot be configured through env because it requires bindings."); | |
| } | |
| if (!storage) | |
| throw new Error("Store is not configured. Either set the `storage` option or set `OPENAUTH_STORAGE` environment variable."); | |
| const allSigning = lazy(() => Promise.all([signingKeys(storage), legacySigningKeys(storage)]).then(([a, b]) => [...a, ...b])); | |
| const allEncryption = lazy(() => encryptionKeys(storage)); | |
| const signingKey = lazy(() => allSigning().then((all) => all[0])); | |
| const encryptionKey = lazy(() => allEncryption().then((all) => all[0])); | |
| const auth = { | |
| async success(ctx, properties, successOpts) { | |
| return await input.success({ | |
| async subject(type, properties2, subjectOpts) { | |
| const authorization = await getAuthorization(ctx); | |
| const subject = subjectOpts?.subject ? subjectOpts.subject : await resolveSubject(type, properties2); | |
| await successOpts?.invalidate?.(await resolveSubject(type, properties2)); | |
| if (authorization.response_type === "token") { | |
| const location = new URL(authorization.redirect_uri); | |
| const tokens = await generateTokens(ctx, { | |
| subject, | |
| type, | |
| properties: properties2, | |
| clientID: authorization.client_id, | |
| ttl: { | |
| access: subjectOpts?.ttl?.access ?? ttlAccess, | |
| refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh | |
| } | |
| }); | |
| location.hash = new URLSearchParams({ | |
| access_token: tokens.access, | |
| refresh_token: tokens.refresh, | |
| state: authorization.state || "" | |
| }).toString(); | |
| await auth.unset(ctx, "authorization"); | |
| return ctx.redirect(location.toString(), 302); | |
| } | |
| if (authorization.response_type === "code") { | |
| const code = crypto.randomUUID(); | |
| await Storage.set(storage, ["oauth:code", code], { | |
| type, | |
| properties: properties2, | |
| subject, | |
| redirectURI: authorization.redirect_uri, | |
| clientID: authorization.client_id, | |
| pkce: authorization.pkce, | |
| ttl: { | |
| access: subjectOpts?.ttl?.access ?? ttlAccess, | |
| refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh | |
| } | |
| }, 60); | |
| const location = new URL(authorization.redirect_uri); | |
| location.searchParams.set("code", code); | |
| location.searchParams.set("state", authorization.state || ""); | |
| await auth.unset(ctx, "authorization"); | |
| return ctx.redirect(location.toString(), 302); | |
| } | |
| throw new OauthError("invalid_request", `Unsupported response_type: ${authorization.response_type}`); | |
| } | |
| }, { | |
| provider: ctx.get("provider"), | |
| ...properties | |
| }, ctx.req.raw); | |
| }, | |
| forward(ctx, response) { | |
| return ctx.newResponse(response.body, response.status, Object.fromEntries(response.headers.entries())); | |
| }, | |
| async set(ctx, key, maxAge, value) { | |
| setCookie(ctx, key, await encrypt(value), { | |
| maxAge, | |
| httpOnly: true, | |
| ...ctx.req.url.startsWith("https://") ? { secure: true, sameSite: "None" } : {} | |
| }); | |
| }, | |
| async get(ctx, key) { | |
| const raw = getCookie(ctx, key); | |
| if (!raw) | |
| return; | |
| return decrypt(raw).catch((ex) => { | |
| console.error("failed to decrypt", key, ex); | |
| }); | |
| }, | |
| async unset(ctx, key) { | |
| deleteCookie(ctx, key); | |
| }, | |
| async invalidate(subject) { | |
| const keys = await Array.fromAsync(Storage.scan(this.storage, ["oauth:refresh", subject])); | |
| for (const [key] of keys) { | |
| await Storage.remove(this.storage, key); | |
| } | |
| }, | |
| storage | |
| }; | |
| async function getAuthorization(ctx) { | |
| const match = await auth.get(ctx, "authorization") || ctx.get("authorization"); | |
| if (!match) | |
| throw new UnknownStateError; | |
| return match; | |
| } | |
| async function encrypt(value) { | |
| return await new CompactEncrypt(new TextEncoder().encode(JSON.stringify(value))).setProtectedHeader({ alg: "RSA-OAEP-512", enc: "A256GCM" }).encrypt(await encryptionKey().then((k) => k.public)); | |
| } | |
| async function resolveSubject(type, properties) { | |
| const jsonString = JSON.stringify(properties); | |
| const encoder = new TextEncoder; | |
| const data = encoder.encode(jsonString); | |
| const hashBuffer = await crypto.subtle.digest("SHA-1", data); | |
| const hashArray = Array.from(new Uint8Array(hashBuffer)); | |
| const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| return `${type}:${hashHex.slice(0, 16)}`; | |
| } | |
| async function generateTokens(ctx, value, opts) { | |
| const refreshToken = value.nextToken ?? crypto.randomUUID(); | |
| if (opts?.generateRefreshToken ?? true) { | |
| const refreshValue = { | |
| ...value, | |
| nextToken: crypto.randomUUID() | |
| }; | |
| delete refreshValue.timeUsed; | |
| await Storage.set(storage, ["oauth:refresh", value.subject, refreshToken], refreshValue, value.ttl.refresh); | |
| } | |
| const accessTimeUsed = Math.floor((value.timeUsed ?? Date.now()) / 1000); | |
| return { | |
| access: await new SignJWT({ | |
| mode: "access", | |
| type: value.type, | |
| properties: value.properties, | |
| aud: value.clientID, | |
| iss: issuer2(ctx), | |
| sub: value.subject | |
| }).setExpirationTime(Math.floor(accessTimeUsed + value.ttl.access)).setProtectedHeader(await signingKey().then((k) => ({ | |
| alg: k.alg, | |
| kid: k.id, | |
| typ: "JWT" | |
| }))).sign(await signingKey().then((item) => item.private)), | |
| expiresIn: Math.floor(accessTimeUsed + value.ttl.access - Date.now() / 1000), | |
| refresh: [value.subject, refreshToken].join(":") | |
| }; | |
| } | |
| async function decrypt(value) { | |
| return JSON.parse(new TextDecoder().decode(await compactDecrypt(value, await encryptionKey().then((v) => v.private)).then((value2) => value2.plaintext))); | |
| } | |
| function issuer2(ctx) { | |
| return new URL(getRelativeUrl(ctx, "/")).origin; | |
| } | |
| const app = new Hono().use(logger()); | |
| for (const [name, value] of Object.entries(input.providers)) { | |
| const route = new Hono; | |
| route.use(async (c, next) => { | |
| c.set("provider", name); | |
| await next(); | |
| }); | |
| value.init(route, { | |
| name, | |
| ...auth | |
| }); | |
| app.route(`/${name}`, route); | |
| } | |
| app.get("/.well-known/jwks.json", cors({ | |
| origin: "*", | |
| allowHeaders: ["*"], | |
| allowMethods: ["GET"], | |
| credentials: false | |
| }), async (c) => { | |
| const all = await allSigning(); | |
| return c.json({ | |
| keys: all.map((item) => ({ | |
| ...item.jwk, | |
| alg: item.alg, | |
| exp: item.expired ? Math.floor(item.expired.getTime() / 1000) : undefined | |
| })) | |
| }); | |
| }); | |
| app.get("/.well-known/oauth-authorization-server", cors({ | |
| origin: "*", | |
| allowHeaders: ["*"], | |
| allowMethods: ["GET"], | |
| credentials: false | |
| }), async (c) => { | |
| const iss = issuer2(c); | |
| return c.json({ | |
| issuer: iss, | |
| authorization_endpoint: `${iss}/authorize`, | |
| token_endpoint: `${iss}/token`, | |
| jwks_uri: `${iss}/.well-known/jwks.json`, | |
| response_types_supported: ["code", "token"] | |
| }); | |
| }); | |
| app.post("/token", cors({ | |
| origin: "*", | |
| allowHeaders: ["*"], | |
| allowMethods: ["POST"], | |
| credentials: false | |
| }), async (c) => { | |
| const form = await c.req.formData(); | |
| const grantType = form.get("grant_type"); | |
| if (grantType === "authorization_code") { | |
| const code = form.get("code"); | |
| if (!code) | |
| return c.json({ | |
| error: "invalid_request", | |
| error_description: "Missing code" | |
| }, 400); | |
| const key = ["oauth:code", code.toString()]; | |
| const payload = await Storage.get(storage, key); | |
| if (!payload) { | |
| return c.json({ | |
| error: "invalid_grant", | |
| error_description: "Authorization code has been used or expired" | |
| }, 400); | |
| } | |
| await Storage.remove(storage, key); | |
| if (payload.redirectURI !== form.get("redirect_uri")) { | |
| return c.json({ | |
| error: "invalid_redirect_uri", | |
| error_description: "Redirect URI mismatch" | |
| }, 400); | |
| } | |
| if (payload.clientID !== form.get("client_id")) { | |
| return c.json({ | |
| error: "unauthorized_client", | |
| error_description: "Client is not authorized to use this authorization code" | |
| }, 403); | |
| } | |
| if (payload.pkce) { | |
| const codeVerifier = form.get("code_verifier")?.toString(); | |
| if (!codeVerifier) | |
| return c.json({ | |
| error: "invalid_grant", | |
| error_description: "Missing code_verifier" | |
| }, 400); | |
| if (!await validatePKCE(codeVerifier, payload.pkce.challenge, payload.pkce.method)) { | |
| return c.json({ | |
| error: "invalid_grant", | |
| error_description: "Code verifier does not match" | |
| }, 400); | |
| } | |
| } | |
| const tokens = await generateTokens(c, payload); | |
| return c.json({ | |
| access_token: tokens.access, | |
| expires_in: tokens.expiresIn, | |
| refresh_token: tokens.refresh | |
| }); | |
| } | |
| if (grantType === "refresh_token") { | |
| const refreshToken = form.get("refresh_token"); | |
| if (!refreshToken) | |
| return c.json({ | |
| error: "invalid_request", | |
| error_description: "Missing refresh_token" | |
| }, 400); | |
| const splits = refreshToken.toString().split(":"); | |
| const token = splits.pop(); | |
| const subject = splits.join(":"); | |
| const key = ["oauth:refresh", subject, token]; | |
| const payload = await Storage.get(storage, key); | |
| if (!payload) { | |
| return c.json({ | |
| error: "invalid_grant", | |
| error_description: "Refresh token has been used or expired" | |
| }, 400); | |
| } | |
| const generateRefreshToken = !payload.timeUsed; | |
| if (ttlRefreshReuse <= 0) { | |
| await Storage.remove(storage, key); | |
| } else if (!payload.timeUsed) { | |
| payload.timeUsed = Date.now(); | |
| await Storage.set(storage, key, payload, ttlRefreshReuse + ttlRefreshRetention); | |
| } else if (Date.now() > payload.timeUsed + ttlRefreshReuse * 1000) { | |
| await auth.invalidate(subject); | |
| return c.json({ | |
| error: "invalid_grant", | |
| error_description: "Refresh token has been used or expired" | |
| }, 400); | |
| } | |
| const tokens = await generateTokens(c, payload, { | |
| generateRefreshToken | |
| }); | |
| return c.json({ | |
| access_token: tokens.access, | |
| refresh_token: tokens.refresh, | |
| expires_in: tokens.expiresIn | |
| }); | |
| } | |
| if (grantType === "client_credentials") { | |
| const provider = form.get("provider"); | |
| if (!provider) | |
| return c.json({ error: "missing `provider` form value" }, 400); | |
| const match = input.providers[provider.toString()]; | |
| if (!match) | |
| return c.json({ error: "invalid `provider` query parameter" }, 400); | |
| if (!match.client) | |
| return c.json({ error: "this provider does not support client_credentials" }, 400); | |
| const clientID = form.get("client_id"); | |
| const clientSecret = form.get("client_secret"); | |
| if (!clientID) | |
| return c.json({ error: "missing `client_id` form value" }, 400); | |
| if (!clientSecret) | |
| return c.json({ error: "missing `client_secret` form value" }, 400); | |
| const response = await match.client({ | |
| clientID: clientID.toString(), | |
| clientSecret: clientSecret.toString(), | |
| params: Object.fromEntries(form) | |
| }); | |
| return input.success({ | |
| async subject(type, properties, opts) { | |
| const tokens = await generateTokens(c, { | |
| type, | |
| subject: opts?.subject || await resolveSubject(type, properties), | |
| properties, | |
| clientID: clientID.toString(), | |
| ttl: { | |
| access: opts?.ttl?.access ?? ttlAccess, | |
| refresh: opts?.ttl?.refresh ?? ttlRefresh | |
| } | |
| }); | |
| return c.json({ | |
| access_token: tokens.access, | |
| refresh_token: tokens.refresh | |
| }); | |
| } | |
| }, { | |
| provider: provider.toString(), | |
| ...response | |
| }, c.req.raw); | |
| } | |
| throw new Error("Invalid grant_type"); | |
| }); | |
| app.get("/authorize", async (c) => { | |
| const provider = c.req.query("provider"); | |
| const response_type = c.req.query("response_type"); | |
| const redirect_uri = c.req.query("redirect_uri"); | |
| const state = c.req.query("state"); | |
| const client_id = c.req.query("client_id"); | |
| const audience = c.req.query("audience"); | |
| const code_challenge = c.req.query("code_challenge"); | |
| const code_challenge_method = c.req.query("code_challenge_method"); | |
| const authorization = { | |
| response_type, | |
| redirect_uri, | |
| state, | |
| client_id, | |
| audience, | |
| pkce: code_challenge && code_challenge_method ? { | |
| challenge: code_challenge, | |
| method: code_challenge_method | |
| } : undefined | |
| }; | |
| c.set("authorization", authorization); | |
| if (!redirect_uri) { | |
| return c.text("Missing redirect_uri", { status: 400 }); | |
| } | |
| if (!response_type) { | |
| throw new MissingParameterError("response_type"); | |
| } | |
| if (!client_id) { | |
| throw new MissingParameterError("client_id"); | |
| } | |
| if (input.start) { | |
| await input.start(c.req.raw); | |
| } | |
| if (!await allow()({ | |
| clientID: client_id, | |
| redirectURI: redirect_uri, | |
| audience | |
| }, c.req.raw)) | |
| throw new UnauthorizedClientError(client_id, redirect_uri); | |
| await auth.set(c, "authorization", 60 * 60 * 24, authorization); | |
| if (provider) | |
| return c.redirect(`/${provider}/authorize`); | |
| const providers = Object.keys(input.providers); | |
| if (providers.length === 1) | |
| return c.redirect(`/${providers[0]}/authorize`); | |
| return auth.forward(c, await select()(Object.fromEntries(Object.entries(input.providers).map(([key, value]) => [ | |
| key, | |
| value.type | |
| ])), c.req.raw)); | |
| }); | |
| app.onError(async (err, c) => { | |
| console.error(err); | |
| if (err instanceof UnknownStateError) { | |
| return auth.forward(c, await error(err, c.req.raw)); | |
| } | |
| const authorization = await getAuthorization(c); | |
| const url = new URL(authorization.redirect_uri); | |
| const oauth = err instanceof OauthError ? err : new OauthError("server_error", err.message); | |
| url.searchParams.set("error", oauth.error); | |
| url.searchParams.set("error_description", oauth.description); | |
| return c.redirect(url.toString()); | |
| }); | |
| return app; | |
| } | |
| export { | |
| issuer, | |
| aws | |
| }; | |
Xet Storage Details
- Size:
- 16.8 kB
- Xet hash:
- 2af33fe39ccb5281e3ff518690891b480d4bd3a8aeedad805718efedf8aa2450
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.