diff --git a/README.md b/README.md index 1014ec89..5eb2ab2c 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,49 @@ const verified = await client.verify(subjects, accessToken) console.log(verified.subject) ``` +#### Resource Indicators + +Use the `resource` parameter to request audience‑restricted access tokens. + +- Request a single resource at authorization: + +```ts +const { challenge, url } = await client.authorize(redirect_uri, "code", { + pkce: true, + resource: ["https://api.myserver.com/"], +}) +``` + +- Request consent for a small set, then select one at the token step: + +```ts +const { challenge, url } = await client.authorize(redirect_uri, "code", { + pkce: true, + resource: ["https://api.myserver.com/", "https://files.myserver.com/"], +}) + +// Later at the token step, pick one resource +const exchanged = await client.exchange( + code, + redirect_uri, + challenge.verifier, + { resource: ["https://api.myserver.com/"] }, +) +``` + +- Introduce a resource during refresh (when allowed by the original grant): + +```ts +const refreshed = await client.refresh(refreshToken, { + resource: ["https://api.myserver.com/"], +}) +``` + +Notes: + +- The server mints single‑audience access tokens only. If multiple `resource` values are sent to `/token`, the request is rejected with `invalid_target`. +- Resource values must be absolute URIs without fragments. Prefer network‑addressable locations (for example, `https://api.myserver.com/`). + --- OpenAuth is created by the maintainers of [SST](https://sst.dev). diff --git a/bun.lockb b/bun.lockb index 7f4b8658..25c80f8d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/openauth/src/client.ts b/packages/openauth/src/client.ts index c4e282a3..d8ff5bed 100644 --- a/packages/openauth/src/client.ts +++ b/packages/openauth/src/client.ts @@ -178,6 +178,19 @@ export interface AuthorizeOptions { * If there's only one provider configured, the user will be redirected to that. */ provider?: string + /** + * Which resource(s) to request access to at authorization. + * + * ```ts + * { + * resource: ["https://api.myapp.com/", "https://files.myapp.com/"] + * } + * ``` + * + * If multiple are provided, the server + * will record them but will require selecting one when exchanging the token. + */ + resources?: string[] } export interface AuthorizeResult { @@ -203,6 +216,14 @@ export interface AuthorizeResult { url: string } +// exchange options +export interface ExchangeOptions { + /** + * The resource to request access to. + */ + resource?: string +} + /** * Returned when the exchange is successful. */ @@ -239,6 +260,11 @@ export interface RefreshOptions { * Optionally, pass in the access token. */ access?: string + /** + * Optionally specify a resource when refreshing. + * The server may restrict this to the set originally authorized. + */ + resource?: string } /** @@ -436,6 +462,7 @@ export interface Client { code: string, redirectURI: string, verifier?: string, + opts?: ExchangeOptions, ): Promise /** * Refreshes the tokens if they have expired. This is used in an SPA app to maintain the @@ -588,6 +615,15 @@ export function createClient(input: ClientInput): Client { result.searchParams.set("response_type", response) result.searchParams.set("state", challenge.state) if (opts?.provider) result.searchParams.set("provider", opts.provider) + + if (opts?.resources) { + const resources = Array.isArray(opts.resources) + ? opts.resources + : [opts.resources] + + for (const r of resources) result.searchParams.append("resource", r) + } + if (opts?.pkce && response === "code") { const pkce = await generatePKCE() result.searchParams.set("code_challenge_method", "S256") @@ -622,19 +658,26 @@ export function createClient(input: ClientInput): Client { code: string, redirectURI: string, verifier?: string, + opts?: ExchangeOptions, ): Promise { + const params = new URLSearchParams({ + code, + redirect_uri: redirectURI, + grant_type: "authorization_code", + client_id: input.clientID, + code_verifier: verifier || "", + }) + + if (opts?.resource) { + params.set("resource", opts.resource) + } + const tokens = await f(issuer + "/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, - body: new URLSearchParams({ - code, - redirect_uri: redirectURI, - grant_type: "authorization_code", - client_id: input.clientID, - code_verifier: verifier || "", - }).toString(), + body: params.toString(), }) const json = (await tokens.json()) as any if (!tokens.ok) { @@ -669,15 +712,19 @@ export function createClient(input: ClientInput): Client { } } } + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refresh, + }) + + if (opts?.resource) params.set("resource", opts.resource) + const tokens = await f(issuer + "/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refresh, - }).toString(), + body: params.toString(), }) const json = (await tokens.json()) as any if (!tokens.ok) { @@ -717,7 +764,7 @@ export function createClient(input: ClientInput): Client { subject: { type: result.payload.type, properties: validated.value, - } as any, + } as VerifyResult["subject"], } return { err: new InvalidSubjectError(), diff --git a/packages/openauth/src/error.ts b/packages/openauth/src/error.ts index b35de0b5..b9c41c27 100644 --- a/packages/openauth/src/error.ts +++ b/packages/openauth/src/error.ts @@ -22,6 +22,7 @@ export class OauthError extends Error { public error: | "invalid_request" | "invalid_grant" + | "invalid_target" | "unauthorized_client" | "access_denied" | "unsupported_grant_type" diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index f4c1f277..14befb01 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -163,6 +163,20 @@ export interface OnSuccessResponder< ): Promise } +export interface AllowCallbackInput { + clientID: string + redirectURI: string + /** + * @deprecated Use `resources` instead + */ + audience?: string + /** + * If provided, the allow hook can use this to restrict which + * APIs/resources a client may request during authorization. + */ + resources?: string[] +} + /** * @internal */ @@ -172,6 +186,7 @@ export interface AuthorizationState { state: string client_id: string audience?: string + resources?: string[] pkce?: { challenge: string method: "S256" @@ -197,7 +212,12 @@ 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 { + getRelativeUrl, + isDomainMatch, + isValidResourceIndicator, + lazy, +} from "./util.js" import { DynamoStorage } from "./storage/dynamo.js" import { MemoryStorage } from "./storage/memory.js" import { cors } from "hono/cors" @@ -417,6 +437,7 @@ export interface IssuerInput< * - 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. + * - If resource indicators are provided, validate that they are valid and that the host follows the same validation rule as the `redirectURI`. * * @example * ```ts @@ -428,14 +449,7 @@ export interface IssuerInput< * } * ``` */ - allow?( - input: { - clientID: string - redirectURI: string - audience?: string - }, - req: Request, - ): Promise + allow?(input: AllowCallbackInput, req: Request): Promise } /** @@ -474,7 +488,7 @@ export function issuer< const allow = lazy( () => input.allow ?? - (async (input: any, req: Request) => { + (async (input: AllowCallbackInput, req: Request) => { const redir = new URL(input.redirectURI).hostname if (redir === "localhost" || redir === "127.0.0.1") { return true @@ -484,6 +498,19 @@ export function issuer< ? new URL(`https://${forwarded}`).hostname : new URL(req.url).hostname + if (input.resources) { + for (const r of input.resources) { + if (!isValidResourceIndicator(r)) { + return false + } + + const resourceHost = new URL(r).hostname + if (!isDomainMatch(resourceHost, host)) { + return false + } + } + } + return isDomainMatch(redir, host) }), ) @@ -525,24 +552,47 @@ export function issuer< ) 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, + + if ( + authorization.resources && + authorization.resources.length > 1 + ) { + throw new OauthError( + "invalid_target", + "Multiple resource values are not supported with implicit flow; request a single resource.", + ) + } + const accessTTL = subjectOpts?.ttl?.access ?? ttlAccess + + const tokens = await generateTokens( + ctx, + { + subject, + type: type as string, + properties, + clientID: authorization.client_id, + audience: authorization.resources?.[0], + ttl: { + access: accessTTL, + refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh, + }, }, - }) + { + // Implicit flow MUST NOT issue refresh tokens (OAuth 2.0 Security BCP) + generateRefreshToken: false, + }, + ) + location.hash = new URLSearchParams({ access_token: tokens.access, - refresh_token: tokens.refresh, + token_type: "Bearer", + expires_in: accessTTL.toString(), 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( @@ -554,6 +604,7 @@ export function issuer< subject, redirectURI: authorization.redirect_uri, clientID: authorization.client_id, + authorizedResources: authorization.resources, pkce: authorization.pkce, ttl: { access: subjectOpts?.ttl?.access ?? ttlAccess, @@ -653,6 +704,8 @@ export function issuer< properties: any subject: string clientID: string + audience?: string + authorizedResources?: string[] ttl: { access: number refresh: number @@ -685,12 +738,13 @@ export function issuer< ) } 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, + aud: value.audience ?? value.clientID, iss: issuer(ctx), sub: value.subject, }) @@ -815,6 +869,8 @@ export function issuer< clientID: string redirectURI: string subject: string + audience?: string + authorizedResources?: string[] ttl: { access: number refresh: number @@ -877,10 +933,83 @@ export function issuer< ) } } - const tokens = await generateTokens(c, payload) + + const tokenResources = form + .getAll("resource") + .filter((v): v is string => typeof v === "string" && v.length > 0) + + if (tokenResources.length > 1) { + return c.json( + { + error: "invalid_target", + error_description: + "Multiple resource values are not supported by this server", + }, + 400, + ) + } + + const requestedResource = tokenResources[0] + + if (requestedResource && !isValidResourceIndicator(requestedResource)) { + return c.json( + { + error: "invalid_target", + error_description: + "The resource value must be an absolute URI with no fragment", + }, + 400, + ) + } + + if ( + requestedResource && + payload.authorizedResources?.length && + !payload.authorizedResources.includes(requestedResource) + ) { + return c.json( + { + error: "invalid_target", + error_description: "Requested resource is not permitted", + }, + 400, + ) + } + + if ( + !requestedResource && + payload.authorizedResources && + payload.authorizedResources.length > 1 + ) { + return c.json( + { + error: "invalid_target", + error_description: + "Resource parameter is required when multiple resources are authorized", + }, + 400, + ) + } + + const selectedResource = + requestedResource ?? + (payload.authorizedResources?.length === 1 + ? payload.authorizedResources[0] + : undefined) + + const tokens = await generateTokens(c, { + type: payload.type, + properties: payload.properties, + subject: payload.subject, + clientID: payload.clientID, + audience: selectedResource, + authorizedResources: payload.authorizedResources, + ttl: payload.ttl, + }) await Storage.remove(storage, key) return c.json({ access_token: tokens.access, + token_type: "Bearer", expires_in: tokens.expiresIn, refresh_token: tokens.refresh, }) @@ -905,6 +1034,8 @@ export function issuer< properties: any clientID: string subject: string + audience?: string + authorizedResources?: string[] ttl: { access: number refresh: number @@ -921,12 +1052,104 @@ export function issuer< 400, ) } + const tokenResources = form + .getAll("resource") + .filter((v): v is string => typeof v === "string" && v.length > 0) + + if (tokenResources.length > 1) { + return c.json( + { + error: "invalid_target", + error_description: + "Multiple resource values are not supported by this server", + }, + 400, + ) + } + + const requestedResource = tokenResources[0] + + if (requestedResource && !isValidResourceIndicator(requestedResource)) { + return c.json( + { + error: "invalid_target", + error_description: + "The resource value must be an absolute URI with no fragment", + }, + 400, + ) + } + + if ( + requestedResource && + payload.authorizedResources?.length && + !payload.authorizedResources.includes(requestedResource) + ) { + return c.json( + { + error: "invalid_target", + error_description: "Requested resource is not permitted", + }, + 400, + ) + } + + if ( + !requestedResource && + payload.authorizedResources && + payload.authorizedResources.length > 1 + ) { + return c.json( + { + error: "invalid_target", + error_description: + "Resource parameter is required when multiple resources are authorized", + }, + 400, + ) + } + + const now = Date.now() + const withinReuse = + typeof payload.timeUsed === "number" && + now <= payload.timeUsed + ttlRefreshReuse * 1000 + + if ( + withinReuse && + requestedResource && + payload.audience && + requestedResource !== payload.audience + ) { + return c.json( + { + error: "invalid_target", + error_description: + "Requested resource is not permitted for this refresh token during reuse window", + }, + 400, + ) + } + + // Select resource: requested, or single authorized, or previous (if in reuse window), or none + let selectedResource = + requestedResource ?? + (payload.authorizedResources?.length === 1 + ? payload.authorizedResources[0] + : undefined) + + if (!selectedResource && withinReuse && payload.audience) { + selectedResource = payload.audience + } + + // Persist reuse-window metadata; lock the selected resource for this window. 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() + if (selectedResource) payload.audience = selectedResource + else delete payload.audience + payload.timeUsed = now await Storage.set( storage, key, @@ -944,13 +1167,29 @@ export function issuer< 400, ) } - const tokens = await generateTokens(c, payload, { - generateRefreshToken, - }) + + const tokens = await generateTokens( + c, + { + type: payload.type, + properties: payload.properties, + subject: payload.subject, + clientID: payload.clientID, + audience: selectedResource, + authorizedResources: payload.authorizedResources, + ttl: payload.ttl, + timeUsed: payload.timeUsed, + nextToken: payload.nextToken, + }, + { + generateRefreshToken, + }, + ) return c.json({ access_token: tokens.access, - refresh_token: tokens.refresh, + token_type: "Bearer", expires_in: tokens.expiresIn, + refresh_token: tokens.refresh, }) } @@ -972,6 +1211,32 @@ export function issuer< return c.json({ error: "missing `client_id` form value" }, 400) if (!clientSecret) return c.json({ error: "missing `client_secret` form value" }, 400) + // Validate resource indicators for client_credentials + const resourceValues = form + .getAll("resource") + .filter((v): v is string => typeof v === "string" && v.length > 0) + if (resourceValues.length > 1) { + return c.json( + { + error: "invalid_target", + error_description: + "Multiple resource values are not supported by this server", + }, + 400, + ) + } + const resourceValue = resourceValues[0] + if (resourceValue && !isValidResourceIndicator(resourceValue)) { + return c.json( + { + error: "invalid_target", + error_description: + "The resource value must be an absolute URI with no fragment", + }, + 400, + ) + } + const response = await match.client({ clientID: clientID.toString(), clientSecret: clientSecret.toString(), @@ -986,6 +1251,10 @@ export function issuer< opts?.subject || (await resolveSubject(type, properties)), properties, clientID: clientID.toString(), + audience: resourceValue?.toString(), + authorizedResources: resourceValue + ? [resourceValue.toString()] + : undefined, ttl: { access: opts?.ttl?.access ?? ttlAccess, refresh: opts?.ttl?.refresh ?? ttlRefresh, @@ -993,6 +1262,8 @@ export function issuer< }) return c.json({ access_token: tokens.access, + token_type: "Bearer", + expires_in: tokens.expiresIn, refresh_token: tokens.refresh, }) }, @@ -1015,7 +1286,22 @@ export function issuer< 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") + // RFC 8707: allow multiple resource parameters at authorization endpoint + const url = new URL(c.req.url) + const resources = url.searchParams.getAll("resource") + // Validate resource indicators early (RFC 8707) + for (const r of resources) { + if (!isValidResourceIndicator(r)) { + return c.json( + { + error: "invalid_target", + error_description: + "The resource value must be an absolute URI with no fragment", + }, + 400, + ) + } + } const code_challenge = c.req.query("code_challenge") const code_challenge_method = c.req.query("code_challenge_method") const authorization: AuthorizationState = { @@ -1023,7 +1309,7 @@ export function issuer< redirect_uri, state, client_id, - audience, + resources: resources.length ? resources : undefined, pkce: code_challenge && code_challenge_method ? { @@ -1050,17 +1336,11 @@ export function issuer< await input.start(c.req.raw) } - if ( - !(await allow()( - { - clientID: client_id, - redirectURI: redirect_uri, - audience, - }, - c.req.raw, - )) + const ok = await allow()( + { clientID: client_id, redirectURI: redirect_uri, resources }, + c.req.raw, ) - throw new UnauthorizedClientError(client_id, redirect_uri) + if (!ok) 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) @@ -1147,8 +1427,21 @@ export function issuer< err instanceof OauthError ? err : new OauthError("server_error", err.message) - url.searchParams.set("error", oauth.error) - url.searchParams.set("error_description", oauth.description) + + if (authorization.response_type === "token") { + url.hash = new URLSearchParams({ + error: oauth.error, + error_description: oauth.description, + state: authorization.state || "", + }).toString() + } else { + url.searchParams.set("error", oauth.error) + url.searchParams.set("error_description", oauth.description) + if (authorization.state) { + url.searchParams.set("state", authorization.state) + } + } + return c.redirect(url.toString()) }) diff --git a/packages/openauth/src/util.ts b/packages/openauth/src/util.ts index 623ad189..4390a2b9 100644 --- a/packages/openauth/src/util.ts +++ b/packages/openauth/src/util.ts @@ -56,3 +56,15 @@ export function lazy(fn: () => T): () => T { return value } } + +/** + * RFC 8707 resource indicator validator: absolute URI with no fragment + */ +export function isValidResourceIndicator(v: string): boolean { + try { + const u = new URL(v) + return u.hash === "" + } catch { + return false + } +} diff --git a/packages/openauth/test/issuer.test.ts b/packages/openauth/test/issuer.test.ts index be303d77..6a5a78d8 100644 --- a/packages/openauth/test/issuer.test.ts +++ b/packages/openauth/test/issuer.test.ts @@ -11,6 +11,7 @@ import { issuer } from "../src/issuer.js" import { createClient } from "../src/client.js" import { createSubjects } from "../src/subject.js" import { MemoryStorage } from "../src/storage/memory.js" +import { StorageAdapter, joinKey, splitKey } from "../src/storage/storage.js" import { Provider } from "../src/provider/provider.js" const subjects = createSubjects({ @@ -117,6 +118,220 @@ describe("code flow", () => { }, }) }) + + test("resource introduced at token step sets aud", async () => { + const client = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(auth.request(a, b)), + }) + const { challenge, url } = await client.authorize( + "https://client.example.com/callback", + "code", + { pkce: true }, + ) + let response = await auth.request(url) + response = await auth.request(response.headers.get("location")!, { + headers: { cookie: response.headers.get("set-cookie")! }, + }) + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code")! + + const exchanged = await client.exchange( + code, + "https://client.example.com/callback", + challenge.verifier, + { resource: "https://api.example.com/" }, + ) + if (exchanged.err) throw exchanged.err + const verified = await client.verify(subjects, exchanged.tokens.access) + expect(verified).toStrictEqual({ + aud: "https://api.example.com/", + subject: { + type: "user", + properties: { userID: "123" }, + }, + }) + }) + + test("multiple resources authorized require selection at token", async () => { + const { challenge, url } = await createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(auth.request(a, b)), + }).authorize("https://client.example.com/callback", "code", { + pkce: true, + resources: ["https://a.example.com/", "https://b.example.com/"], + }) + let response = await auth.request(url) + response = await auth.request(response.headers.get("location")!, { + headers: { cookie: response.headers.get("set-cookie")! }, + }) + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code")! + const p = new URLSearchParams() + p.set("grant_type", "authorization_code") + p.set("code", code) + p.set("client_id", "123") + p.set("redirect_uri", "https://client.example.com/callback") + p.set("code_verifier", challenge.verifier || "") + const tokenResp = await auth.request("https://auth.example.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: p.toString(), + }) + expect(tokenResp.status).toBe(400) + const body = await tokenResp.json() + expect(body.error).toBe("invalid_target") + }) + + test("resource mismatch at token is invalid_target", async () => { + const client = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(auth.request(a, b)), + }) + const { challenge, url } = await client.authorize( + "https://client.example.com/callback", + "code", + { pkce: true, resources: ["https://a.example.com/"] }, + ) + let response = await auth.request(url) + response = await auth.request(response.headers.get("location")!, { + headers: { cookie: response.headers.get("set-cookie")! }, + }) + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code")! + const p2 = new URLSearchParams() + p2.set("grant_type", "authorization_code") + p2.set("code", code) + p2.set("client_id", "123") + p2.set("redirect_uri", "https://client.example.com/callback") + p2.set("code_verifier", challenge.verifier || "") + p2.set("resource", "https://b.example.com/") + const tokenResp = await auth.request("https://auth.example.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: p2.toString(), + }) + expect(tokenResp.status).toBe(400) + const body = await tokenResp.json() + expect(body.error).toBe("invalid_target") + }) + + test("invalid resource syntax at token is invalid_target", async () => { + const client = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(auth.request(a, b)), + }) + const { challenge, url } = await client.authorize( + "https://client.example.com/callback", + "code", + { pkce: true }, + ) + let response = await auth.request(url) + response = await auth.request(response.headers.get("location")!, { + headers: { cookie: response.headers.get("set-cookie")! }, + }) + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code")! + const bad = new URLSearchParams() + bad.set("grant_type", "authorization_code") + bad.set("code", code) + bad.set("client_id", "123") + bad.set("redirect_uri", "https://client.example.com/callback") + bad.set("code_verifier", challenge.verifier || "") + bad.set("resource", "https://api.example.com/#bad") + const tokenResp = await auth.request("https://auth.example.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: bad.toString(), + }) + expect(tokenResp.status).toBe(400) + const body = await tokenResp.json() + expect(body.error).toBe("invalid_target") + }) +}) + +describe("implicit flow", () => { + test("success without refresh token", async () => { + // Build an implicit authorize URL (response_type=token) + const url = new URL("https://auth.example.com/authorize") + url.searchParams.set("client_id", "123") + url.searchParams.set("redirect_uri", "https://client.example.com/callback") + url.searchParams.set("response_type", "token") + url.searchParams.set("provider", "dummy") + + // First hop sets auth state and redirects to provider + let response = await auth.request(url.toString()) + expect(response.status).toBe(302) + + // Provider completes and triggers implicit success + response = await auth.request(response.headers.get("location")!, { + headers: { + cookie: response.headers.get("set-cookie")!, + }, + }) + + // Should redirect back to client with tokens in fragment (OAuth 2.0 implicit flow spec) + expect(response.status).toBe(302) + const location = new URL(response.headers.get("location")!) + expect(location.origin + location.pathname).toBe( + "https://client.example.com/callback", + ) + + // Tokens should be in the fragment (hash), not query params + const fragmentParams = new URLSearchParams(location.hash.substring(1)) + + // MUST have access_token + expect(fragmentParams.has("access_token")).toBe(true) + expect(fragmentParams.get("access_token")).toMatch(/.+/) + + // MUST have token_type (RFC 6749 Section 4.2.2) + expect(fragmentParams.get("token_type")).toBe("Bearer") + + // MUST NOT have refresh_token (OAuth 2.0 Security Best Current Practice) + expect(fragmentParams.has("refresh_token")).toBe(false) + + // SHOULD have state for CSRF protection + expect(fragmentParams.has("state")).toBe(true) + }) + + test("multiple resources are rejected", async () => { + // Build an implicit authorize URL with two resource parameters + const url = new URL("https://auth.example.com/authorize") + url.searchParams.set("client_id", "123") + url.searchParams.set("redirect_uri", "https://client.example.com/callback") + url.searchParams.set("response_type", "token") + url.searchParams.append("resource", "https://a.example.com/") + url.searchParams.append("resource", "https://b.example.com/") + + // First hop sets auth state and redirects to provider + let response = await auth.request(url.toString()) + expect(response.status).toBe(302) + // Provider completes and triggers implicit success; expect invalid_target error in redirect + response = await auth.request(response.headers.get("location")!, { + headers: { + cookie: response.headers.get("set-cookie")!, + }, + }) + + // Should redirect back to client with error in fragment (OAuth 2.0 implicit flow spec) + expect(response.status).toBe(302) + const location = new URL(response.headers.get("location")!) + expect(location.origin + location.pathname).toBe( + "https://client.example.com/callback", + ) + + // Error should be in the fragment (hash), not query params + const fragmentParams = new URLSearchParams(location.hash.substring(1)) + expect(fragmentParams.get("error")).toBe("invalid_target") + expect(fragmentParams.get("error_description")).toBe( + "Multiple resource values are not supported with implicit flow; request a single resource.", + ) + expect(fragmentParams.has("state")).toBe(true) + }) }) describe("client credentials flow", () => { @@ -126,22 +341,24 @@ describe("client credentials flow", () => { clientID: "123", fetch: (a, b) => Promise.resolve(auth.request(a, b)), }) + const cc1 = new URLSearchParams() + cc1.set("grant_type", "client_credentials") + cc1.set("provider", "dummy") + cc1.set("client_id", "myuser") + cc1.set("client_secret", "mypass") const response = await auth.request("https://auth.example.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, - body: new URLSearchParams({ - grant_type: "client_credentials", - provider: "dummy", - client_id: "myuser", - client_secret: "mypass", - }).toString(), + body: cc1.toString(), }) expect(response.status).toBe(200) const tokens = await response.json() expect(tokens).toStrictEqual({ access_token: expectNonEmptyString, + token_type: "Bearer", + expires_in: expect.any(Number), refresh_token: expectNonEmptyString, }) const verified = await client.verify(subjects, tokens.access_token) @@ -155,6 +372,28 @@ describe("client credentials flow", () => { }, }) }) + + test("multiple resources not supported in client_credentials", async () => { + const cc2 = new URLSearchParams() + cc2.set("grant_type", "client_credentials") + cc2.set("provider", "dummy") + cc2.set("client_id", "myuser") + cc2.set("client_secret", "mypass") + cc2.set("resource", "https://a.example.com/") + const response = await auth.request("https://auth.example.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: + cc2.toString() + + "&resource=" + + encodeURIComponent("https://b.example.com/"), + }) + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe("invalid_target") + }) }) describe("refresh token", () => { @@ -221,8 +460,9 @@ describe("refresh token", () => { const refreshed = await response.json() expect(refreshed).toStrictEqual({ access_token: expectNonEmptyString, - refresh_token: expectNonEmptyString, + token_type: "Bearer", expires_in: expect.any(Number), + refresh_token: expectNonEmptyString, }) expect(refreshed.access_token).not.toEqual(tokens.access) expect(refreshed.refresh_token).not.toEqual(tokens.refresh) @@ -239,6 +479,137 @@ describe("refresh token", () => { }) }) + test("introduce resource on refresh when none set", async () => { + const rpar = new URLSearchParams() + rpar.set("grant_type", "refresh_token") + rpar.set("refresh_token", tokens.refresh) + rpar.set("resource", "https://api.example.com/") + const response = await auth.request("https://auth.example.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: rpar.toString(), + }) + expect(response.status).toBe(200) + const body = await response.json() + const client = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(auth.request(a, b)), + }) + const verified = await client.verify(subjects, body.access_token) + expect(verified).toStrictEqual({ + aud: "https://api.example.com/", + subject: { + type: "user", + properties: { userID: "123" }, + }, + }) + }) + + test("refresh enforces authorized resource set", async () => { + // First acquire tokens with two authorized resources by doing auth with two resource params + const authz = issuer({ + ...issuerConfig, + }) + const cl = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(authz.request(a, b)), + }) + const { challenge, url } = await cl.authorize( + "https://client.example.com/callback", + "code", + { + pkce: true, + resources: ["https://a.example.com/", "https://b.example.com/"], + }, + ) + let response = await authz.request(url) + response = await authz.request(response.headers.get("location")!, { + headers: { cookie: response.headers.get("set-cookie")! }, + }) + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code")! + // Exchange for tokens bound to https://a.example.com/ + const exchanged = await cl.exchange( + code, + "https://client.example.com/callback", + challenge.verifier, + { resource: "https://a.example.com/" }, + ) + if (exchanged.err) throw exchanged.err + const firstTokens = { + access_token: exchanged.tokens.access, + refresh_token: exchanged.tokens.refresh, + } + // Now try to refresh into an unauthorized resource + const p3 = new URLSearchParams() + p3.set("grant_type", "refresh_token") + p3.set("refresh_token", firstTokens.refresh_token) + p3.set("resource", "https://c.example.com/") + const refresh = await authz.request("https://auth.example.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: p3.toString(), + }) + expect(refresh.status).toBe(400) + const body = await refresh.json() + expect(body.error).toBe("invalid_target") + }) + + test("refresh can switch to another originally authorized resource", async () => { + const authz = issuer({ + ...issuerConfig, + }) + const cl = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(authz.request(a, b)), + }) + const { challenge, url } = await cl.authorize( + "https://client.example.com/callback", + "code", + { + pkce: true, + resources: ["https://a.example.com/", "https://b.example.com/"], + }, + ) + let response = await authz.request(url) + response = await authz.request(response.headers.get("location")!, { + headers: { cookie: response.headers.get("set-cookie")! }, + }) + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code")! + // Exchange picking resource A + const exchanged = await cl.exchange( + code, + "https://client.example.com/callback", + challenge.verifier, + { resource: "https://a.example.com/" }, + ) + if (exchanged.err) throw exchanged.err + // Now refresh to pick resource B (also authorized originally) + const p = new URLSearchParams() + p.set("grant_type", "refresh_token") + p.set("refresh_token", exchanged.tokens.refresh) + p.set("resource", "https://b.example.com/") + const r = await authz.request("https://auth.example.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: p.toString(), + }) + expect(r.status).toBe(200) + const tokens = await r.json() + const verified = await cl.verify(subjects, tokens.access_token) + expect(verified).toStrictEqual({ + aud: "https://b.example.com/", + subject: { + type: "user", + properties: { userID: "123" }, + }, + }) + }) + test("success with valid access token", async () => { // have to increment the time so new access token claims are different (i.e. exp) setSystemTime(Date.now() + 1000) @@ -247,8 +618,9 @@ describe("refresh token", () => { const refreshed = await response.json() expect(refreshed).toStrictEqual({ access_token: expectNonEmptyString, - refresh_token: expectNonEmptyString, + token_type: "Bearer", expires_in: expect.any(Number), + refresh_token: expectNonEmptyString, }) expect(refreshed.access_token).not.toEqual(tokens.access) @@ -325,6 +697,97 @@ describe("refresh token", () => { expect(response.status).toBe(400) }) + test("reuse window persists resource before metadata (serialized store)", async () => { + // Storage adapter that clones values to simulate serialization (no shared refs) + const CloneStorage = (): StorageAdapter => { + const map = new Map< + string, + { value: Record; exp?: number } + >() + return { + async get(key) { + const k = joinKey(key) + const e = map.get(k) + if (!e) return undefined + if (e.exp && Date.now() >= e.exp) return undefined + return JSON.parse(JSON.stringify(e.value)) + }, + async set(key, value, expiry) { + const k = joinKey(key) + map.set(k, { + value: JSON.parse(JSON.stringify(value)), + exp: expiry?.getTime(), + }) + }, + async remove(key) { + map.delete(joinKey(key)) + }, + async *scan(prefix) { + const p = joinKey(prefix) + for (const [k, v] of map) { + if (!k.startsWith(p)) continue + if (v.exp && Date.now() >= v.exp) continue + yield [splitKey(k), JSON.parse(JSON.stringify(v.value))] + } + }, + } + } + + const custom = issuer({ + ...issuerConfig, + storage: CloneStorage(), + }) + // Authorize and exchange to get initial refresh + const client = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(custom.request(a, b)), + }) + const { challenge, url } = await client.authorize( + "https://client.example.com/callback", + "code", + { pkce: true }, + ) + let response = await custom.request(url) + response = await custom.request(response.headers.get("location")!, { + headers: { cookie: response.headers.get("set-cookie")! }, + }) + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code")! + const first = await client.exchange( + code, + "https://client.example.com/callback", + challenge.verifier, + ) + if (first.err) throw first.err + + // First refresh selects resource A + const r1p = new URLSearchParams() + r1p.set("grant_type", "refresh_token") + r1p.set("refresh_token", first.tokens.refresh) + r1p.set("resource", "https://a.example.com/") + const r1 = await custom.request("https://auth.example.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: r1p.toString(), + }) + expect(r1.status).toBe(200) + + // Second refresh reuses same original token but attempts different resource B + const p4 = new URLSearchParams() + p4.set("grant_type", "refresh_token") + p4.set("refresh_token", first.tokens.refresh) + p4.set("resource", "https://b.example.com/") + const r2 = await custom.request("https://auth.example.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: p4.toString(), + }) + expect(r2.status).toBe(400) + const body = await r2.json() + expect(body.error).toBe("invalid_target") + }) + test("expired failure", async () => { setSystemTime(Date.now() + 1000 * 6000 + 1000) let response = await requestRefreshToken(tokens.refresh) diff --git a/packages/openauth/test/util.test.ts b/packages/openauth/test/util.test.ts index 08e22e0f..e18687d1 100644 --- a/packages/openauth/test/util.test.ts +++ b/packages/openauth/test/util.test.ts @@ -1,6 +1,10 @@ import { expect, test } from "bun:test" import { Context } from "hono" -import { getRelativeUrl, isDomainMatch } from "../src/util.js" +import { + getRelativeUrl, + isDomainMatch, + isValidResourceIndicator, +} from "../src/util.js" test("isDomainMatch", () => { // Basic matches @@ -101,3 +105,14 @@ test("getRelativeUrl", () => { "http://other.com/path", ) }) + +test("isValidResourceIndicator - accepts absolute URIs without fragments", () => { + expect(isValidResourceIndicator("https://api.example.com")).toBe(true) + expect(isValidResourceIndicator("urn:example:resource")).toBe(true) +}) + +test("isValidResourceIndicator - rejects relative and with fragment", () => { + expect(isValidResourceIndicator("/relative")).toBe(false) + expect(isValidResourceIndicator("https://api.example.com/#frag")).toBe(false) + expect(isValidResourceIndicator("")).toBe(false) +})