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
8 changes: 8 additions & 0 deletions .changeset/odd-baboons-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@openauthjs/openauth": patch
---

Adds support for refresh token reuse interval and reuse detection

Also fixes an issue with token invalidation, where removing keys while scanning
may cause some refresh tokens to be skipped (depending on storage provider.)
97 changes: 76 additions & 21 deletions packages/openauth/src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,17 +326,25 @@ export interface IssuerInput<
*/
ttl?: {
/**
* The TTL for access tokens.
*
* @default 60 * 60 * 24 * 30
* Interval in seconds where the access token is valid.
* @default 30d
*/
access?: number
/**
* The TTL for refresh tokens.
*
* @default 60 * 60 * 24 * 365
* Interval in seconds where the refresh token is valid.
* @default 1y
*/
refresh?: number
/**
* Interval in seconds where refresh token reuse is allowed. 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
Expand Down Expand Up @@ -453,6 +461,8 @@ export function issuer<
}
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)
}
Expand Down Expand Up @@ -592,10 +602,11 @@ export function issuer<
deleteCookie(ctx, key)
},
async invalidate(subject: string) {
for await (const [key] of Storage.scan(this.storage, [
"oauth:refresh",
subject,
])) {
// 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)
}
},
Expand Down Expand Up @@ -640,17 +651,33 @@ export function issuer<
access: number
refresh: number
}
timeUsed?: number
nextToken?: string
},
opts?: {
generateRefreshToken?: boolean
},
) {
const refreshToken = crypto.randomUUID()
await Storage.set(
storage!,
["oauth:refresh", value.subject, refreshToken],
{
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,
},
value.ttl.refresh,
)
nextToken: crypto.randomUUID(),
}
delete refreshValue.timeUsed
await Storage.set(
storage!,
["oauth:refresh", value.subject, refreshToken],
refreshValue,
value.ttl.refresh,
)
}
return {
access: await new SignJWT({
mode: "access",
Expand All @@ -660,7 +687,9 @@ export function issuer<
iss: issuer(ctx),
sub: value.subject,
})
.setExpirationTime(Date.now() / 1000 + value.ttl.access)
.setExpirationTime(
Math.floor((value.timeUsed ?? Date.now()) / 1000 + value.ttl.access),
)
.setProtectedHeader(
await signingKey.then((k) => ({
alg: k.alg,
Expand Down Expand Up @@ -854,6 +883,8 @@ export function issuer<
access: number
refresh: number
}
nextToken: string
timeUsed?: number
}>(storage, key)
if (!payload) {
return c.json(
Expand All @@ -864,8 +895,32 @@ export function issuer<
400,
)
}
await Storage.remove(storage, key)
const tokens = await generateTokens(c, payload)
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,
Expand Down
118 changes: 92 additions & 26 deletions packages/openauth/test/issuer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ const subjects = createSubjects({
})

let storage = MemoryStorage()
const auth = issuer({
const issuerConfig = {
storage,
subjects,
allow: async () => true,
ttl: {
access: 60,
refresh: 6000,
refreshReuse: 60,
refreshRetention: 6000,
},
providers: {
dummy: {
Expand Down Expand Up @@ -56,7 +58,8 @@ const auth = issuer({
}
throw new Error("Invalid provider: " + value.provider)
},
})
}
const auth = issuer(issuerConfig)

const expectNonEmptyString = expect.stringMatching(/.+/)

Expand Down Expand Up @@ -157,33 +160,16 @@ describe("refresh token", () => {
let tokens: { access: string; refresh: string }
let client: ReturnType<typeof createClient>

const requestRefreshToken = async (refresh_token: string) =>
auth.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 ? { refresh_token } : {}),
}).toString(),
})

beforeEach(async () => {
client = createClient({
issuer: "https://auth.example.com",
clientID: "123",
fetch: (a, b) => Promise.resolve(auth.request(a, b)),
})
const generateTokens = async (issuer: typeof auth) => {
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")!, {
let response = await issuer.request(url)
response = await issuer.request(response.headers.get("location")!, {
headers: {
cookie: response.headers.get("set-cookie")!,
},
Expand All @@ -196,7 +182,35 @@ describe("refresh token", () => {
challenge.verifier,
)
if (exchanged.err) throw exchanged.err
tokens = exchanged.tokens
return exchanged.tokens
}

const createClientAndTokens = async (issuer: typeof auth) => {
client = createClient({
issuer: "https://auth.example.com",
clientID: "123",
fetch: (a, b) => Promise.resolve(issuer.request(a, b)),
})
tokens = await generateTokens(issuer)
}

const requestRefreshToken = async (
refresh_token: string,
issuer?: typeof auth,
) =>
(issuer ?? auth).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 ? { refresh_token } : {}),
}).toString(),
})

beforeEach(async () => {
await createClientAndTokens(auth)
})

test("success", async () => {
Expand Down Expand Up @@ -249,19 +263,71 @@ describe("refresh token", () => {
})
})

test("expired failure", async () => {
setSystemTime(Date.now() + 1000 * 6000 + 1000)
test("multiple active tokens", async () => {
const tokens2 = await generateTokens(auth)

let response = await requestRefreshToken(tokens.refresh)
expect(response.status).toBe(200)

response = await requestRefreshToken(tokens2.refresh)
expect(response.status).toBe(200)
})

test("failure with reuse interval disabled", async () => {
const issuerWithoutReuse = issuer({
...issuerConfig,
ttl: {
...issuerConfig.ttl,
refreshReuse: 0,
refreshRetention: 0,
},
})
await createClientAndTokens(issuerWithoutReuse)
let response = await requestRefreshToken(
tokens.refresh,
issuerWithoutReuse,
)
expect(response.status).toBe(200)

response = await requestRefreshToken(tokens.refresh, issuerWithoutReuse)
expect(response.status).toBe(400)
const reused = await response.json()
expect(reused.error).toBe("invalid_grant")
})

test("reuse failure", async () => {
test("success with reuse interval enabled", async () => {
let response = await requestRefreshToken(tokens.refresh)
expect(response.status).toBe(200)
const refreshed = await response.json()
const [, refreshedAccessPayload] = refreshed.access_token.split(".")

setSystemTime(Date.now() + 1000 * 30)

response = await requestRefreshToken(tokens.refresh)
expect(response.status).toBe(200)
const reused = await response.json()
const [, reusedAccessPayload] = reused.access_token.split(".")
expect(refreshed.refresh_token).toEqual(reused.refresh_token)
/**
* Access token signature is different every time for ES256 alg,
* but the payload should be the same.
*/
expect(refreshedAccessPayload).toEqual(reusedAccessPayload)
})

test("invalidated with reuse detection", async () => {
let response = await requestRefreshToken(tokens.refresh)
expect(response.status).toBe(200)

setSystemTime(Date.now() + 1000 * 60 + 1000)

response = await requestRefreshToken(tokens.refresh)
expect(response.status).toBe(400)
})

test("expired failure", async () => {
setSystemTime(Date.now() + 1000 * 6000 + 1000)
let response = await requestRefreshToken(tokens.refresh)
expect(response.status).toBe(400)
const reused = await response.json()
expect(reused.error).toBe("invalid_grant")
Expand Down
Loading