Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion UPSTREAM_PR_PRIORITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
| 261 | https://github.com/anomalyco/openauth/pull/261 | Low | [ ] | Docs typo fix. |
| 260 | https://github.com/anomalyco/openauth/pull/260 | Low | [ ] | Docs update about Zod Standard Schema support. |
| 243 | https://github.com/anomalyco/openauth/pull/243 | Low | [ ] | Fixes typo in `standalone.mdx`. |
| 319 | https://github.com/anomalyco/openauth/pull/319 | Low | [ ] | Adds optional `refresh()` callback to update claims during refresh-token flows. |
| 319 | https://github.com/anomalyco/openauth/pull/319 | Low | [x] | Adds optional `refresh()` callback to update claims during refresh-token flows. |
| 320 | https://github.com/anomalyco/openauth/pull/320 | Low | [x] | Adds `D1Storage` adapter for Cloudflare D1. |
| 303 | https://github.com/anomalyco/openauth/pull/303 | Low | [ ] | WIP for RFC 8707 resource indicators. |
| 284 | https://github.com/anomalyco/openauth/pull/284 | Low | [ ] | Adds support for native iOS/macOS Sign in with Apple token validation flow. |
Expand Down
69 changes: 69 additions & 0 deletions packages/openauth/src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,38 @@ export interface IssuerInput<
input: Result,
req: Request,
): Promise<Response>
/**
* 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<SubjectPayload<Subjects>>,
input: {
type: string
properties: any
subject: string
clientID: string
},
req: Request,
): Promise<Response>
/**
* @internal
*/
Expand Down Expand Up @@ -962,6 +994,43 @@ 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,
})
Expand Down
112 changes: 111 additions & 1 deletion packages/openauth/test/issuer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
setSystemTime,
test,
} from "bun:test"
import { object, string } from "valibot"
import { array, object, optional, string } from "valibot"
import { createClient } from "../src/client.js"
import { issuer } from "../src/issuer.js"
import type { Provider } from "../src/provider/provider.js"
Expand All @@ -16,6 +16,7 @@ import { createSubjects } from "../src/subject.js"
const subjects = createSubjects({
user: object({
userID: string(),
permissions: optional(array(string())),
}),
})

Expand Down Expand Up @@ -475,6 +476,115 @@ 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", () => {
Expand Down