Skip to content
Open
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
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions packages/openauth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@openauthjs/openauth",
"version": "0.4.3",
"license": "MIT",
"type": "module",
"scripts": {
"build": "bun run script/build.ts",
Expand Down
70 changes: 70 additions & 0 deletions packages/openauth/src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,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 @@ -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,
})
Expand Down
103 changes: 102 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 {
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"
Expand All @@ -16,6 +16,7 @@ import { Provider } from "../src/provider/provider.js"
const subjects = createSubjects({
user: object({
userID: string(),
permissions: optional(array(string()))
}),
})

Expand Down Expand Up @@ -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", () => {
Expand Down