Skip to content

Commit d1a7074

Browse files
a-lavisascorbic
andauthored
Fix Two OAuth token refresh bugs (#155)
* add WWW-Authenticate header when token is invalid * set separate refresh expires at on oauth tokens * Add changeset --------- Co-authored-by: Matt Kane <m@mk.gg>
1 parent 6e4d81d commit d1a7074

8 files changed

Lines changed: 318 additions & 32 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@getcirrus/oauth-provider": patch
3+
"@getcirrus/pds": patch
4+
---
5+
6+
Fix two OAuth token refresh bugs that prevented spec-compliant clients (e.g. tangled.org via indigo) from refreshing their session after the access token expired.
7+
8+
- Track access and refresh expiry separately on `TokenData` (`accessExpiresAt` / `refreshExpiresAt`) instead of a single `expiresAt`. `cleanup()` now prunes by `refreshExpiresAt`, so a row isn't deleted while its refresh token is still valid. The PDS SQLite store migrates legacy `oauth_tokens` rows in place, deriving `refresh_expires_at` as `MAX(expires_at, issued_at + REFRESH_TOKEN_TTL)`.
9+
- The PDS auth middleware now sends `WWW-Authenticate: DPoP error="invalid_token"` on 401 responses for invalid/expired OAuth access tokens, as required by the atproto XRPC spec. Clients that gate refresh on this header (indigo, and others) will now refresh automatically instead of staying logged-in-but-broken until the user signs out.

packages/oauth-provider/src/storage.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ export interface TokenData {
4141
dpopJkt?: string;
4242
/** Issuance timestamp (Unix ms) */
4343
issuedAt: number;
44-
/** Expiration timestamp (Unix ms) */
45-
expiresAt: number;
44+
/** Access token expiration timestamp (Unix ms) */
45+
accessExpiresAt: number;
46+
/** Refresh token expiration timestamp (Unix ms) */
47+
refreshExpiresAt: number;
4648
/** Whether the token has been revoked */
4749
revoked?: boolean;
4850
}
@@ -258,7 +260,7 @@ export class InMemoryOAuthStorage implements OAuthStorage {
258260
async getTokenByAccess(accessToken: string): Promise<TokenData | null> {
259261
const data = this.tokens.get(accessToken);
260262
if (!data) return null;
261-
if (data.revoked || Date.now() > data.expiresAt) {
263+
if (data.revoked || Date.now() > data.accessExpiresAt) {
262264
return null;
263265
}
264266
return data;
@@ -269,8 +271,7 @@ export class InMemoryOAuthStorage implements OAuthStorage {
269271
if (!accessToken) return null;
270272
const data = this.tokens.get(accessToken);
271273
if (!data) return null;
272-
if (data.revoked) return null;
273-
// Refresh tokens don't use accessToken expiresAt
274+
if (data.revoked || Date.now() > data.refreshExpiresAt) return null;
274275
return data;
275276
}
276277

packages/oauth-provider/src/tokens.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export function generateTokens(options: GenerateTokensOptions): {
8585
scope,
8686
dpopJkt,
8787
accessTokenTtl = ACCESS_TOKEN_TTL,
88+
refreshTokenTtl = REFRESH_TOKEN_TTL,
8889
} = options;
8990

9091
const accessToken = generateRandomToken(32);
@@ -99,7 +100,8 @@ export function generateTokens(options: GenerateTokensOptions): {
99100
scope,
100101
dpopJkt,
101102
issuedAt: now,
102-
expiresAt: now + accessTokenTtl,
103+
accessExpiresAt: now + accessTokenTtl,
104+
refreshExpiresAt: now + refreshTokenTtl,
103105
revoked: false,
104106
};
105107

@@ -120,12 +122,14 @@ export function generateTokens(options: GenerateTokensOptions): {
120122
* @param existingData The existing token data
121123
* @param rotateRefreshToken Whether to generate a new refresh token
122124
* @param accessTokenTtl Custom access token TTL in ms
125+
* @param refreshTokenTtl Custom refresh token TTL in ms (used when rotating)
123126
* @returns Updated tokens and token data
124127
*/
125128
export function refreshTokens(
126129
existingData: TokenData,
127130
rotateRefreshToken: boolean = false,
128131
accessTokenTtl: number = ACCESS_TOKEN_TTL,
132+
refreshTokenTtl: number = REFRESH_TOKEN_TTL,
129133
): {
130134
tokens: GeneratedTokens;
131135
tokenData: TokenData;
@@ -141,7 +145,10 @@ export function refreshTokens(
141145
accessToken,
142146
refreshToken,
143147
issuedAt: now,
144-
expiresAt: now + accessTokenTtl,
148+
accessExpiresAt: now + accessTokenTtl,
149+
refreshExpiresAt: rotateRefreshToken
150+
? now + refreshTokenTtl
151+
: existingData.refreshExpiresAt,
145152
};
146153

147154
const tokens: GeneratedTokens = {
@@ -214,7 +221,7 @@ export function isTokenValid(tokenData: TokenData): boolean {
214221
if (tokenData.revoked) {
215222
return false;
216223
}
217-
if (Date.now() > tokenData.expiresAt) {
224+
if (Date.now() > tokenData.accessExpiresAt) {
218225
return false;
219226
}
220227
return true;

packages/pds/src/middleware/auth.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ export async function requireAuth(
115115
message: "Invalid OAuth access token",
116116
},
117117
401,
118+
{
119+
"WWW-Authenticate":
120+
'DPoP error="invalid_token", error_description="Invalid access token"',
121+
},
118122
);
119123
}
120124

packages/pds/src/oauth-storage.ts

Lines changed: 90 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type {
2-
AuthCodeData,
3-
ClientMetadata,
4-
LexiconPermissionSet,
5-
OAuthStorage,
6-
PARData,
7-
TokenData,
1+
import {
2+
type AuthCodeData,
3+
type ClientMetadata,
4+
type OAuthStorage,
5+
type LexiconPermissionSet,
6+
type PARData,
7+
type TokenData,
8+
REFRESH_TOKEN_TTL,
89
} from "@getcirrus/oauth-provider";
910

1011
/**
@@ -53,13 +54,11 @@ export class SqliteOAuthStorage implements OAuthStorage {
5354
scope TEXT NOT NULL,
5455
dpop_jkt TEXT,
5556
issued_at INTEGER NOT NULL,
56-
expires_at INTEGER NOT NULL,
57+
access_expires_at INTEGER NOT NULL,
58+
refresh_expires_at INTEGER NOT NULL,
5759
revoked INTEGER NOT NULL DEFAULT 0
5860
);
5961
60-
CREATE INDEX IF NOT EXISTS idx_tokens_refresh ON oauth_tokens(refresh_token);
61-
CREATE INDEX IF NOT EXISTS idx_tokens_sub ON oauth_tokens(sub);
62-
CREATE INDEX IF NOT EXISTS idx_tokens_expires ON oauth_tokens(expires_at);
6362
6463
-- Cached client metadata
6564
CREATE TABLE IF NOT EXISTS oauth_clients (
@@ -112,8 +111,10 @@ export class SqliteOAuthStorage implements OAuthStorage {
112111
CREATE INDEX IF NOT EXISTS idx_permission_sets_expires ON oauth_permission_sets(expires_at);
113112
`);
114113

115-
// Migration: add columns for client auth metadata if missing
114+
// Migrations for older OAuth schema versions
116115
this.migrateClientTable();
116+
this.migrateTokensTable();
117+
this.ensureTokenIndexes();
117118
}
118119

119120
private migrateClientTable(): void {
@@ -132,14 +133,75 @@ export class SqliteOAuthStorage implements OAuthStorage {
132133
}
133134
}
134135

136+
private migrateTokensTable(): void {
137+
const columns = this.sql
138+
.exec("PRAGMA table_info(oauth_tokens)")
139+
.toArray()
140+
.map((r) => r.name as string);
141+
142+
if (columns.length === 0) return;
143+
if (
144+
columns.includes("access_expires_at") &&
145+
columns.includes("refresh_expires_at")
146+
) {
147+
return;
148+
}
149+
150+
this.sql.exec(`
151+
CREATE TABLE oauth_tokens_new (
152+
access_token TEXT PRIMARY KEY,
153+
refresh_token TEXT NOT NULL UNIQUE,
154+
client_id TEXT NOT NULL,
155+
sub TEXT NOT NULL,
156+
scope TEXT NOT NULL,
157+
dpop_jkt TEXT,
158+
issued_at INTEGER NOT NULL,
159+
access_expires_at INTEGER NOT NULL,
160+
refresh_expires_at INTEGER NOT NULL,
161+
revoked INTEGER NOT NULL DEFAULT 0
162+
);
163+
`);
164+
165+
this.sql.exec(
166+
`INSERT INTO oauth_tokens_new (
167+
access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, access_expires_at, refresh_expires_at, revoked
168+
)
169+
SELECT
170+
access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at,
171+
expires_at AS access_expires_at,
172+
MAX(expires_at, issued_at + ?) AS refresh_expires_at,
173+
revoked
174+
FROM oauth_tokens`,
175+
REFRESH_TOKEN_TTL,
176+
);
177+
178+
this.sql.exec("DROP TABLE oauth_tokens");
179+
this.sql.exec("ALTER TABLE oauth_tokens_new RENAME TO oauth_tokens");
180+
}
181+
182+
private ensureTokenIndexes(): void {
183+
this.sql.exec(
184+
"CREATE INDEX IF NOT EXISTS idx_tokens_refresh ON oauth_tokens(refresh_token)",
185+
);
186+
this.sql.exec(
187+
"CREATE INDEX IF NOT EXISTS idx_tokens_sub ON oauth_tokens(sub)",
188+
);
189+
this.sql.exec(
190+
"CREATE INDEX IF NOT EXISTS idx_tokens_access_expires ON oauth_tokens(access_expires_at)",
191+
);
192+
this.sql.exec(
193+
"CREATE INDEX IF NOT EXISTS idx_tokens_refresh_expires ON oauth_tokens(refresh_expires_at)",
194+
);
195+
}
196+
135197
/**
136198
* Clean up expired entries. Should be called periodically.
137199
*/
138200
cleanup(): void {
139201
const now = Date.now();
140202
this.sql.exec("DELETE FROM oauth_auth_codes WHERE expires_at < ?", now);
141203
this.sql.exec(
142-
"DELETE FROM oauth_tokens WHERE expires_at < ? AND revoked = 0",
204+
"DELETE FROM oauth_tokens WHERE refresh_expires_at < ?",
143205
now,
144206
);
145207
this.sql.exec("DELETE FROM oauth_par_requests WHERE expires_at < ?", now);
@@ -219,24 +281,25 @@ export class SqliteOAuthStorage implements OAuthStorage {
219281
async saveTokens(data: TokenData): Promise<void> {
220282
this.sql.exec(
221283
`INSERT INTO oauth_tokens
222-
(access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, expires_at, revoked)
223-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
284+
(access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, access_expires_at, refresh_expires_at, revoked)
285+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
224286
data.accessToken,
225287
data.refreshToken,
226288
data.clientId,
227289
data.sub,
228290
data.scope,
229291
data.dpopJkt ?? null,
230292
data.issuedAt,
231-
data.expiresAt,
293+
data.accessExpiresAt,
294+
data.refreshExpiresAt,
232295
data.revoked ? 1 : 0,
233296
);
234297
}
235298

236299
async getTokenByAccess(accessToken: string): Promise<TokenData | null> {
237300
const rows = this.sql
238301
.exec(
239-
`SELECT access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, expires_at, revoked
302+
`SELECT access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, access_expires_at, refresh_expires_at, revoked
240303
FROM oauth_tokens WHERE access_token = ?`,
241304
accessToken,
242305
)
@@ -246,9 +309,9 @@ export class SqliteOAuthStorage implements OAuthStorage {
246309

247310
const row = rows[0]!;
248311
const revoked = Boolean(row.revoked);
249-
const expiresAt = row.expires_at as number;
312+
const accessExpiresAt = row.access_expires_at as number;
250313

251-
if (revoked || Date.now() > expiresAt) {
314+
if (revoked || Date.now() > accessExpiresAt) {
252315
return null;
253316
}
254317

@@ -260,15 +323,16 @@ export class SqliteOAuthStorage implements OAuthStorage {
260323
scope: row.scope as string,
261324
dpopJkt: (row.dpop_jkt as string) ?? undefined,
262325
issuedAt: row.issued_at as number,
263-
expiresAt,
326+
accessExpiresAt,
327+
refreshExpiresAt: row.refresh_expires_at as number,
264328
revoked,
265329
};
266330
}
267331

268332
async getTokenByRefresh(refreshToken: string): Promise<TokenData | null> {
269333
const rows = this.sql
270334
.exec(
271-
`SELECT access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, expires_at, revoked
335+
`SELECT access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, access_expires_at, refresh_expires_at, revoked
272336
FROM oauth_tokens WHERE refresh_token = ?`,
273337
refreshToken,
274338
)
@@ -279,7 +343,9 @@ export class SqliteOAuthStorage implements OAuthStorage {
279343
const row = rows[0]!;
280344
const revoked = Boolean(row.revoked);
281345

282-
if (revoked) return null;
346+
const refreshExpiresAt = row.refresh_expires_at as number;
347+
348+
if (revoked || Date.now() > refreshExpiresAt) return null;
283349

284350
return {
285351
accessToken: row.access_token as string,
@@ -289,7 +355,8 @@ export class SqliteOAuthStorage implements OAuthStorage {
289355
scope: row.scope as string,
290356
dpopJkt: (row.dpop_jkt as string) ?? undefined,
291357
issuedAt: row.issued_at as number,
292-
expiresAt: row.expires_at as number,
358+
accessExpiresAt: row.access_expires_at as number,
359+
refreshExpiresAt,
293360
revoked,
294361
};
295362
}

0 commit comments

Comments
 (0)