Buckets:
| /** | |
| * The `issuer` create an OpentAuth server, a [Hono](https://hono.dev) app that's | |
| * designed to run anywhere. | |
| * | |
| * The `issuer` function requires a few things: | |
| * | |
| * ```ts title="issuer.ts" | |
| * import { issuer } from "@openauthjs/openauth" | |
| * | |
| * const app = issuer({ | |
| * providers: { ... }, | |
| * storage, | |
| * subjects, | |
| * success: async (ctx, value) => { ... } | |
| * }) | |
| * ``` | |
| * | |
| * #### Add providers | |
| * | |
| * You start by specifying the auth providers you are going to use. Let's say you want your users | |
| * to be able to authenticate with GitHub and with their email and password. | |
| * | |
| * ```ts title="issuer.ts" | |
| * import { GithubProvider } from "@openauthjs/openauth/provider/github" | |
| * import { PasswordProvider } from "@openauthjs/openauth/provider/password" | |
| * | |
| * const app = issuer({ | |
| * providers: { | |
| * github: GithubProvider({ | |
| * // ... | |
| * }), | |
| * password: PasswordProvider({ | |
| * // ... | |
| * }), | |
| * }, | |
| * }) | |
| * ``` | |
| * | |
| * #### Handle success | |
| * | |
| * The `success` callback receives the payload when a user completes a provider's auth flow. | |
| * | |
| * ```ts title="issuer.ts" | |
| * const app = issuer({ | |
| * providers: { ... }, | |
| * subjects, | |
| * async success(ctx, value) { | |
| * let userID | |
| * if (value.provider === "password") { | |
| * console.log(value.email) | |
| * userID = ... // lookup user or create them | |
| * } | |
| * if (value.provider === "github") { | |
| * console.log(value.tokenset.access) | |
| * userID = ... // lookup user or create them | |
| * } | |
| * return ctx.subject("user", { | |
| * userID | |
| * }) | |
| * } | |
| * }) | |
| * ``` | |
| * | |
| * Once complete, the `issuer` issues the access tokens that a client can use. The `ctx.subject` | |
| * call is what is placed in the access token as a JWT. | |
| * | |
| * #### Define subjects | |
| * | |
| * You define the shape of these in the `subjects` field. | |
| * | |
| * ```ts title="subjects.ts" | |
| * import { object, string } from "valibot" | |
| * import { createSubjects } from "@openauthjs/openauth/subject" | |
| * | |
| * const subjects = createSubjects({ | |
| * user: object({ | |
| * userID: string() | |
| * }) | |
| * }) | |
| * ``` | |
| * | |
| * It's good to place this in a separate file since this'll be used in your client apps as well. | |
| * | |
| * ```ts title="issuer.ts" | |
| * import { subjects } from "./subjects.js" | |
| * | |
| * const app = issuer({ | |
| * providers: { ... }, | |
| * subjects, | |
| * // ... | |
| * }) | |
| * ``` | |
| * | |
| * #### Deploy | |
| * | |
| * Since `issuer` is a Hono app, you can deploy it anywhere Hono supports. | |
| * | |
| * <Tabs> | |
| * <TabItem label="Node"> | |
| * ```ts title="issuer.ts" | |
| * import { serve } from "@hono/node-server" | |
| * | |
| * serve(app) | |
| * ``` | |
| * </TabItem> | |
| * <TabItem label="Lambda"> | |
| * ```ts title="issuer.ts" | |
| * import { handle } from "hono/aws-lambda" | |
| * | |
| * export const handler = handle(app) | |
| * ``` | |
| * </TabItem> | |
| * <TabItem label="Bun"> | |
| * ```ts title="issuer.ts" | |
| * export default app | |
| * ``` | |
| * </TabItem> | |
| * <TabItem label="Workers"> | |
| * ```ts title="issuer.ts" | |
| * export default app | |
| * ``` | |
| * </TabItem> | |
| * </Tabs> | |
| * | |
| * @packageDocumentation | |
| */ | |
| import { Provider, ProviderOptions } from "./provider/provider.js" | |
| import { SubjectPayload, SubjectSchema } from "./subject.js" | |
| import { Hono } from "hono/tiny" | |
| import { handle as awsHandle } from "hono/aws-lambda" | |
| import { Context } from "hono" | |
| import { deleteCookie, getCookie, setCookie } from "hono/cookie" | |
| /** | |
| * Sets the subject payload in the JWT token and returns the response. | |
| * | |
| * ```ts | |
| * ctx.subject("user", { | |
| * userID | |
| * }) | |
| * ``` | |
| */ | |
| export interface OnSuccessResponder< | |
| T extends { type: string; properties: any }, | |
| > { | |
| /** | |
| * The `type` is the type of the subject, that was defined in the `subjects` field. | |
| * | |
| * The `properties` are the properties of the subject. This is the shape of the subject that | |
| * you defined in the `subjects` field. | |
| */ | |
| subject<Type extends T["type"]>( | |
| type: Type, | |
| properties: Extract<T, { type: Type }>["properties"], | |
| opts?: { | |
| ttl?: { | |
| access?: number | |
| refresh?: number | |
| } | |
| subject?: string | |
| }, | |
| ): Promise<Response> | |
| } | |
| /** | |
| * @internal | |
| */ | |
| export interface AuthorizationState { | |
| redirect_uri: string | |
| response_type: string | |
| state: string | |
| client_id: string | |
| audience?: string | |
| pkce?: { | |
| challenge: string | |
| method: "S256" | |
| } | |
| } | |
| /** | |
| * @internal | |
| */ | |
| export type Prettify<T> = { | |
| [K in keyof T]: T[K] | |
| } & {} | |
| import { | |
| MissingParameterError, | |
| OauthError, | |
| UnauthorizedClientError, | |
| UnknownStateError, | |
| } from "./error.js" | |
| import { compactDecrypt, CompactEncrypt, SignJWT } from "jose" | |
| import { Storage, StorageAdapter } from "./storage/storage.js" | |
| import { encryptionKeys, legacySigningKeys, signingKeys } from "./keys.js" | |
| import { validatePKCE } from "./pkce.js" | |
| import { Select } from "./ui/select.js" | |
| import { setTheme, Theme } 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" | |
| /** @internal */ | |
| export const aws = awsHandle | |
| export interface IssuerInput< | |
| Providers extends Record<string, Provider<any>>, | |
| Subjects extends SubjectSchema, | |
| Result = { | |
| [key in keyof Providers]: Prettify< | |
| { | |
| provider: key | |
| } & (Providers[key] extends Provider<infer T> ? T : {}) | |
| > | |
| }[keyof Providers], | |
| > { | |
| /** | |
| * The shape of the subjects that you want to return. | |
| * | |
| * @example | |
| * | |
| * ```ts title="issuer.ts" | |
| * import { object, string } from "valibot" | |
| * import { createSubjects } from "@openauthjs/openauth/subject" | |
| * | |
| * issuer({ | |
| * subjects: createSubjects({ | |
| * user: object({ | |
| * userID: string() | |
| * }) | |
| * }) | |
| * // ... | |
| * }) | |
| * ``` | |
| */ | |
| subjects: Subjects | |
| /** | |
| * The storage adapter that you want to use. | |
| * | |
| * @example | |
| * ```ts title="issuer.ts" | |
| * import { DynamoStorage } from "@openauthjs/openauth/storage/dynamo" | |
| * | |
| * issuer({ | |
| * storage: DynamoStorage() | |
| * // ... | |
| * }) | |
| * ``` | |
| */ | |
| storage?: StorageAdapter | |
| /** | |
| * The providers that you want your OpenAuth server to support. | |
| * | |
| * @example | |
| * | |
| * ```ts title="issuer.ts" | |
| * import { GithubProvider } from "@openauthjs/openauth/provider/github" | |
| * | |
| * issuer({ | |
| * providers: { | |
| * github: GithubProvider() | |
| * } | |
| * }) | |
| * ``` | |
| * | |
| * The key is just a string that you can use to identify the provider. It's passed back to | |
| * the `success` callback. | |
| * | |
| * You can also specify multiple providers. | |
| * | |
| * ```ts | |
| * { | |
| * providers: { | |
| * github: GithubProvider(), | |
| * google: GoogleProvider() | |
| * } | |
| * } | |
| * ``` | |
| */ | |
| providers: Providers | |
| /** | |
| * The theme you want to use for the UI. | |
| * | |
| * This includes the UI the user sees when selecting a provider. And the `PasswordUI` and | |
| * `CodeUI` that are used by the `PasswordProvider` and `CodeProvider`. | |
| * | |
| * @example | |
| * ```ts title="issuer.ts" | |
| * import { THEME_SST } from "@openauthjs/openauth/ui/theme" | |
| * | |
| * issuer({ | |
| * theme: THEME_SST | |
| * // ... | |
| * }) | |
| * ``` | |
| * | |
| * Or define your own. | |
| * | |
| * ```ts title="issuer.ts" | |
| * import type { Theme } from "@openauthjs/openauth/ui/theme" | |
| * | |
| * const MY_THEME: Theme = { | |
| * // ... | |
| * } | |
| * | |
| * issuer({ | |
| * theme: MY_THEME | |
| * // ... | |
| * }) | |
| * ``` | |
| */ | |
| theme?: Theme | |
| /** | |
| * Set the TTL, in seconds, for access and refresh tokens. | |
| * | |
| * @example | |
| * ```ts | |
| * { | |
| * ttl: { | |
| * access: 60 * 60 * 24 * 30, | |
| * refresh: 60 * 60 * 24 * 365 | |
| * } | |
| * } | |
| * ``` | |
| */ | |
| ttl?: { | |
| /** | |
| * Interval in seconds where the access token is valid. | |
| * @default 30d | |
| */ | |
| access?: number | |
| /** | |
| * Interval in seconds where the refresh token is valid. | |
| * @default 1y | |
| */ | |
| refresh?: number | |
| /** | |
| * Interval in seconds where refresh token reuse is allowed. This helps mitigrate | |
| * concurrency issues. | |
| * @default 60s | |
| */ | |
| reuse?: number | |
| /** | |
| * Interval in seconds to retain refresh tokens for reuse detection. | |
| * @default 0s | |
| */ | |
| retention?: number | |
| } | |
| /** | |
| * Optionally, configure the UI that's displayed when the user visits the root URL of the | |
| * of the OpenAuth server. | |
| * | |
| * ```ts title="issuer.ts" | |
| * import { Select } from "@openauthjs/openauth/ui/select" | |
| * | |
| * issuer({ | |
| * select: Select({ | |
| * providers: { | |
| * github: { hide: true }, | |
| * google: { display: "Google" } | |
| * } | |
| * }) | |
| * // ... | |
| * }) | |
| * ``` | |
| * | |
| * @default Select() | |
| */ | |
| select?(providers: Record<string, string>, req: Request): Promise<Response> | |
| /** | |
| * @internal | |
| */ | |
| start?(req: Request): Promise<void> | |
| /** | |
| * The success callback that's called when the user completes the flow. | |
| * | |
| * This is called after the user has been redirected back to your app after the OAuth flow. | |
| * | |
| * @example | |
| * ```ts | |
| * { | |
| * success: async (ctx, value) => { | |
| * let userID | |
| * if (value.provider === "password") { | |
| * console.log(value.email) | |
| * userID = ... // lookup user or create them | |
| * } | |
| * if (value.provider === "github") { | |
| * console.log(value.tokenset.access) | |
| * userID = ... // lookup user or create them | |
| * } | |
| * return ctx.subject("user", { | |
| * userID | |
| * }) | |
| * }, | |
| * // ... | |
| * } | |
| * ``` | |
| */ | |
| success( | |
| response: OnSuccessResponder<SubjectPayload<Subjects>>, | |
| input: Result, | |
| req: Request, | |
| ): Promise<Response> | |
| /** | |
| * @internal | |
| */ | |
| error?(error: UnknownStateError, req: Request): Promise<Response> | |
| /** | |
| * Override the logic for whether a client request is allowed to call the issuer. | |
| * | |
| * By default, it uses the following: | |
| * | |
| * - Allow if the `redirectURI` is localhost. | |
| * - Compare `redirectURI` to the request's hostname or the `x-forwarded-host` header. If they | |
| * are from the same sub-domain level, then allow. | |
| * | |
| * @example | |
| * ```ts | |
| * { | |
| * allow: async (input, req) => { | |
| * // Allow all clients | |
| * return true | |
| * } | |
| * } | |
| * ``` | |
| */ | |
| allow?( | |
| input: { | |
| clientID: string | |
| redirectURI: string | |
| audience?: string | |
| }, | |
| req: Request, | |
| ): Promise<boolean> | |
| } | |
| /** | |
| * Create an OpenAuth server, a Hono app. | |
| */ | |
| export function issuer< | |
| Providers extends Record<string, Provider<any>>, | |
| Subjects extends SubjectSchema, | |
| Result = { | |
| [key in keyof Providers]: Prettify< | |
| { | |
| provider: key | |
| } & (Providers[key] extends Provider<infer T> ? T : {}) | |
| > | |
| }[keyof Providers], | |
| >(input: IssuerInput<Providers, Subjects, Result>) { | |
| 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 (input: any, req: Request) => { | |
| const redir = new URL(input.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: Omit<ProviderOptions<any>, "name"> = { | |
| async success(ctx: Context, properties: any, successOpts) { | |
| return await input.success( | |
| { | |
| async subject(type, properties, subjectOpts) { | |
| const authorization = await getAuthorization(ctx) | |
| const subject = subjectOpts?.subject | |
| ? subjectOpts.subject | |
| : await resolveSubject(type, properties) | |
| await successOpts?.invalidate?.( | |
| await resolveSubject(type, properties), | |
| ) | |
| if (authorization.response_type === "token") { | |
| const location = new URL(authorization.redirect_uri) | |
| const tokens = await generateTokens(ctx, { | |
| subject, | |
| type: type as string, | |
| properties, | |
| 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, | |
| 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 as any, | |
| 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: Context, key: string) { | |
| const raw = getCookie(ctx, key) | |
| if (!raw) return | |
| return decrypt(raw).catch((ex) => { | |
| console.error("failed to decrypt", key, ex) | |
| }) | |
| }, | |
| async unset(ctx: Context, key: string) { | |
| deleteCookie(ctx, key) | |
| }, | |
| async invalidate(subject: string) { | |
| // Resolve the scan in case modifications interfere with iteration | |
| 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: Context) { | |
| const match = | |
| (await auth.get(ctx, "authorization")) || ctx.get("authorization") | |
| if (!match) throw new UnknownStateError() | |
| return match as AuthorizationState | |
| } | |
| async function encrypt(value: any) { | |
| 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: string, properties: any) { | |
| 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: Context, | |
| value: { | |
| type: string | |
| properties: any | |
| subject: string | |
| clientID: string | |
| ttl: { | |
| access: number | |
| refresh: number | |
| } | |
| timeUsed?: number | |
| nextToken?: string | |
| }, | |
| opts?: { | |
| generateRefreshToken?: boolean | |
| }, | |
| ) { | |
| const refreshToken = value.nextToken ?? crypto.randomUUID() | |
| if (opts?.generateRefreshToken ?? true) { | |
| /** | |
| * Generate and store the next refresh token after the one we are currently returning. | |
| * Reserving these in advance avoids concurrency issues with multiple refreshes. | |
| * Similar treatment should be given to any other values that may have race conditions, | |
| * for example if a jti claim was added to the access token. | |
| */ | |
| 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: issuer(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: string) { | |
| return JSON.parse( | |
| new TextDecoder().decode( | |
| await compactDecrypt( | |
| value, | |
| await encryptionKey().then((v) => v.private), | |
| ).then((value) => value.plaintext), | |
| ), | |
| ) | |
| } | |
| function issuer(ctx: Context) { | |
| return new URL(getRelativeUrl(ctx, "/")).origin | |
| } | |
| const app = new Hono<{ | |
| Variables: { | |
| authorization: AuthorizationState | |
| } | |
| }>().use(logger()) | |
| for (const [name, value] of Object.entries(input.providers)) { | |
| const route = new Hono<any>() | |
| 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 = issuer(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<{ | |
| type: string | |
| properties: any | |
| clientID: string | |
| redirectURI: string | |
| subject: string | |
| ttl: { | |
| access: number | |
| refresh: number | |
| } | |
| pkce?: AuthorizationState["pkce"] | |
| }>(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<{ | |
| type: string | |
| properties: any | |
| clientID: string | |
| subject: string | |
| ttl: { | |
| access: number | |
| refresh: number | |
| } | |
| nextToken: string | |
| timeUsed?: number | |
| }>(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) { | |
| // no reuse interval, remove the refresh token immediately | |
| 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) { | |
| // token was reused past the allowed interval | |
| 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) as Record<string, string>, | |
| }) | |
| return input.success( | |
| { | |
| async subject(type, properties, opts) { | |
| const tokens = await generateTokens(c, { | |
| type: type as string, | |
| 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: AuthorizationState = { | |
| response_type, | |
| redirect_uri, | |
| state, | |
| client_id, | |
| audience, | |
| pkce: | |
| code_challenge && code_challenge_method | |
| ? { | |
| challenge: code_challenge, | |
| method: code_challenge_method, | |
| } | |
| : undefined, | |
| } as AuthorizationState | |
| 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 | |
| } | |
Xet Storage Details
- Size:
- 30.3 kB
- Xet hash:
- ed4da85f3a4d9f419b7bc61a6e0685dfffe4293104a462ecca62dc9023657986
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.