diff --git a/bun.lockb b/bun.lockb index 7f4b8658..291418d4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/openauth/package.json b/packages/openauth/package.json index a5e3276b..a2ac5351 100644 --- a/packages/openauth/package.json +++ b/packages/openauth/package.json @@ -1,6 +1,7 @@ { "name": "@openauthjs/openauth", "version": "0.4.3", + "license": "MIT", "type": "module", "scripts": { "build": "bun run script/build.ts", diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index f4c1f277..931c7f2c 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -405,6 +405,38 @@ export interface IssuerInput< input: Result, req: Request, ): Promise + /** + * Optional callback that's called when a refresh token is used to get new access tokens. + * + * This allows you to update dynamic user attributes (permissions, roles, etc.) during + * token refresh without requiring the user to re-authenticate. + * + * If not provided, the original properties from the initial authentication will be reused. + * + * @example + * ```ts + * { + * refresh: async (ctx, value) => { + * // Fetch updated permissions from database + * const permissions = await db.getPermissions(value.properties.userId) + * return ctx.subject("user", { + * ...value.properties, + * permissions // Updated value + * }) + * } + * } + * ``` + */ + refresh?( + response: OnSuccessResponder>, + input: { + type: string + properties: any + subject: string + clientID: string + }, + req: Request, + ): Promise /** * @internal */ @@ -944,6 +976,44 @@ export function issuer< 400, ) } + + // If refresh callback is provided, call it to allow updating properties + if (input.refresh) { + return input.refresh( + { + async subject(type, properties, opts) { + const tokens = await generateTokens( + c, + { + type: type as string, + subject: opts?.subject || payload.subject, + properties, + clientID: payload.clientID, + ttl: { + access: opts?.ttl?.access ?? ttlAccess, + refresh: opts?.ttl?.refresh ?? ttlRefresh, + }, + }, + { generateRefreshToken }, + ) + return c.json({ + access_token: tokens.access, + refresh_token: tokens.refresh, + expires_in: tokens.expiresIn, + }) + }, + }, + { + type: payload.type, + properties: payload.properties, + subject: payload.subject, + clientID: payload.clientID, + }, + c.req.raw, + ) + } + + // Fallback: use existing cached properties const tokens = await generateTokens(c, payload, { generateRefreshToken, }) diff --git a/packages/openauth/test/issuer.test.ts b/packages/openauth/test/issuer.test.ts index be303d77..b3384d9f 100644 --- a/packages/openauth/test/issuer.test.ts +++ b/packages/openauth/test/issuer.test.ts @@ -6,7 +6,7 @@ import { beforeEach, afterEach, } from "bun:test" -import { object, string } from "valibot" +import { object, string, array, optional } from "valibot" import { issuer } from "../src/issuer.js" import { createClient } from "../src/client.js" import { createSubjects } from "../src/subject.js" @@ -16,6 +16,7 @@ import { Provider } from "../src/provider/provider.js" const subjects = createSubjects({ user: object({ userID: string(), + permissions: optional(array(string())) }), }) @@ -339,6 +340,106 @@ describe("refresh token", () => { const reused = await response.json() expect(reused.error).toBe("invalid_request") }) + + test("refresh callback updates properties", async () => { + let refreshCallCount = 0 + const refreshedSubjects = createSubjects({ + user: object({ + userID: string(), + permissions: optional(array(string())), + }), + }) + const issuerWithRefresh = issuer({ + ...issuerConfig, + subjects: refreshedSubjects, + refresh: async (ctx, value) => { + refreshCallCount++ + expect(value.type).toBe("user") + expect(value.properties).toStrictEqual({ userID: "123" }) + expect(value.subject).toMatch(/^user:[a-f0-9]+$/) + expect(value.clientID).toBe("123") + + return ctx.subject("user", { + userID: "123", + permissions: ["read", "write"], + }) + }, + }) + + const client = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(issuerWithRefresh.request(a, b)), + }) + + // Generate initial tokens + const { challenge, url } = await client.authorize( + "https://client.example.com/callback", + "code", + { pkce: true }, + ) + let response = await issuerWithRefresh.request(url) + response = await issuerWithRefresh.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, + ) + if (exchanged.err) throw exchanged.err + const initialTokens = exchanged.tokens + + // Verify initial token doesn't have permissions (just has userID) + const initialVerified = await client.verify(refreshedSubjects, initialTokens.access) + if (initialVerified.err) throw initialVerified.err + expect(initialVerified.subject.type).toBe("user") + expect(initialVerified.subject.properties.userID).toBe("123") + expect(initialVerified.subject.properties.permissions).toBeUndefined() + expect(refreshCallCount).toBe(0) + + // Refresh the token + setSystemTime(Date.now() + 1000 * 60 + 1000) + response = await issuerWithRefresh.request("https://auth.example.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: initialTokens.refresh, + }).toString(), + }) + expect(response.status).toBe(200) + const refreshed = await response.json() + expect(refreshCallCount).toBe(1) + + // Verify refreshed token has updated properties including permissions + const refreshedVerified = await client.verify( + refreshedSubjects, + refreshed.access_token, + ) + expect(refreshedVerified).toStrictEqual({ + aud: "123", + subject: { + type: "user", + properties: { + userID: "123", + permissions: ["read", "write"], + }, + }, + }) + if (refreshedVerified.err) throw refreshedVerified.err + // Explicitly verify permissions were added by the refresh callback + expect(refreshedVerified.subject.properties.permissions).toStrictEqual([ + "read", + "write", + ]) + }) }) describe("user info", () => {