From 7924b72745878dc1c3a9d3256e09500bf63b9690 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Tue, 9 Jun 2026 12:39:46 -0500 Subject: [PATCH] Allow OAuth refresh after access expiry --- src/atproto/oauth.zig | 5 ++-- src/internal/passkeys.zig | 12 ++++----- src/storage/store.zig | 52 ++++++++++++++++++++++++++++++++++----- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/atproto/oauth.zig b/src/atproto/oauth.zig index f083a24..7f9c672 100644 --- a/src/atproto/oauth.zig +++ b/src/atproto/oauth.zig @@ -446,10 +446,9 @@ fn refreshToken(request: *http_api.Request, allocator: std.mem.Allocator, params log.err("oauth refresh invalid_grant: refresh token not found token_len={d} client={s}\n", .{ refresh.len, client_id }); return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); }; - const ts = now(); const client_id_match = std.mem.eql(u8, token_row.client_id, client_id); - if (token_row.revoked or token_row.expires_at < ts or !client_id_match) { - log.err("oauth refresh invalid_grant: revoked={} expires_at={d} now={d} client_id_match={} row_client={s} request_client={s} did={s}\n", .{ token_row.revoked, token_row.expires_at, ts, client_id_match, token_row.client_id, client_id, token_row.did }); + if (token_row.revoked or !client_id_match) { + log.err("oauth refresh invalid_grant: revoked={} access_expires_at={d} client_id_match={} row_client={s} request_client={s} did={s}\n", .{ token_row.revoked, token_row.expires_at, client_id_match, token_row.client_id, client_id, token_row.did }); return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); } const account = (try store.findAccount(allocator, token_row.did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found"); diff --git a/src/internal/passkeys.zig b/src/internal/passkeys.zig index 5028a72..0eb5759 100644 --- a/src/internal/passkeys.zig +++ b/src/internal/passkeys.zig @@ -206,7 +206,7 @@ pub fn adminSessionsPage(request: *http_api.Request) !void { \\ \\ \\ @@ -639,15 +639,15 @@ const adminSessionsScript = \\const time=(value)=>{if(!value)return{short:'never',full:'never'};const d=new Date(value);return{short:d.toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}),full:d.toLocaleString([], {dateStyle:'full',timeStyle:'long'})}}; \\const methodLabel=(s)=>{if(s.appPasswordName)return`app password: ${s.appPasswordName}`;if(s.authMethod==='password')return'account password token';if(s.authMethod==='app_password')return'app password token';if(s.authMethod==='app_password_privileged')return'privileged app password token';return s.authMethod}; \\const shownItems=()=>{const source=kind==='grants'?grants:sessions;if(filter==='all')return source;if(filter==='usable')return source.filter(item=>item.active);return source.filter(item=>!item.active)}; - \\const activeTitle=(isGrant)=>isGrant?'OAuth token row is active when it has not expired and has not been revoked.':'Direct API token family is active when it has not been revoked and either its access token or refresh token is still unexpired.'; - \\const inactiveTitle=(isGrant)=>isGrant?'OAuth token row is inactive when it is expired or revoked.':'Direct API token family is inactive when it is revoked or both access and refresh tokens are expired.'; + \\const activeTitle=(isGrant)=>isGrant?'OAuth app session is active when its current refresh token has not been revoked. The one-hour access token may still need refresh.':'Direct API token family is active when it has not been revoked and either its access token or refresh token is still unexpired.'; + \\const inactiveTitle=(isGrant)=>isGrant?'OAuth app session is inactive when its token row has been revoked, usually by refresh rotation or explicit revocation.':'Direct API token family is inactive when it is revoked or both access and refresh tokens are expired.'; \\const pill=(active,isGrant)=>{const span=document.createElement('span');span.className=`pill ${active?'usable':'ended'}`;span.textContent=active?'active':'inactive';span.title=active?activeTitle(isGrant):inactiveTitle(isGrant);return span}; \\const stamp=(label,value)=>{const div=document.createElement('div');div.className='stamp';const span=document.createElement('span');span.textContent=label;const t=time(value);const body=document.createElement('time');body.textContent=t.short;body.title=t.full;body.dateTime=value||'';div.append(span,body);return div}; \\const oauthAuthMethod=(g)=>g.authMethod==='passkey'?'passkey':g.authMethod==='password'?'password':'unknown method'; - \\const card=(item)=>{const isGrant=kind==='grants';const el=document.createElement('article');el.className=`session-card ${item.active?'usable':'ended'}`;const top=document.createElement('div');top.className='card-top';const main=document.createElement('div');const who=document.createElement('div');who.className='who';who.textContent=item.handle||item.did||'unknown account';const sub=document.createElement('span');sub.className='did';sub.textContent=item.did||'';main.append(who,sub);top.append(main,pill(item.active,isGrant));const what=document.createElement('div');what.className='what';what.textContent=isGrant?item.clientId:methodLabel(item);const scope=document.createElement('span');scope.className='scope';scope.textContent=isGrant?`${oauthAuthMethod(item)} authorization · ${item.scope}`:(item.controllerDid?`controller ${item.controllerDid}`:'com.atproto.server.createSession token family');what.append(scope);const grid=document.createElement('div');grid.className='card-grid';if(isGrant){grid.append(stamp('authorized',item.createdAt),stamp('token row expires',item.expiresAt),stamp('revoked',item.revokedAt))}else{grid.append(stamp('created',item.createdAt),stamp('last used',item.lastUsedAt),stamp('refresh token expires',item.refreshExpiresAt))}el.append(top,what,grid);return el}; + \\const card=(item)=>{const isGrant=kind==='grants';const el=document.createElement('article');el.className=`session-card ${item.active?'usable':'ended'}`;const top=document.createElement('div');top.className='card-top';const main=document.createElement('div');const who=document.createElement('div');who.className='who';who.textContent=item.handle||item.did||'unknown account';const sub=document.createElement('span');sub.className='did';sub.textContent=item.did||'';main.append(who,sub);top.append(main,pill(item.active,isGrant));const what=document.createElement('div');what.className='what';what.textContent=isGrant?item.clientId:methodLabel(item);const scope=document.createElement('span');scope.className='scope';scope.textContent=isGrant?`${oauthAuthMethod(item)} authorization · ${item.scope}`:(item.controllerDid?`controller ${item.controllerDid}`:'com.atproto.server.createSession token family');what.append(scope);const grid=document.createElement('div');grid.className='card-grid';if(isGrant){grid.append(stamp('authorized',item.createdAt),stamp('access token expires',item.expiresAt),stamp('revoked',item.revokedAt))}else{grid.append(stamp('created',item.createdAt),stamp('last used',item.lastUsedAt),stamp('refresh token expires',item.refreshExpiresAt))}el.append(top,what,grid);return el}; \\const renderPager=(total)=>{pagerRoot.textContent='';if(total<=pageSize)return;const pages=Math.ceil(total/pageSize);const prev=document.createElement('button');prev.type='button';prev.textContent='prev';prev.disabled=page===0;prev.onclick=()=>{page--;render()};const label=document.createElement('span');label.textContent=`${page+1}/${pages}`;const next=document.createElement('button');next.type='button';next.textContent='next';next.disabled=page>=pages-1;next.onclick=()=>{page++;render()};pagerRoot.append(prev,label,next)}; - \\const renderControls=()=>{grantCount.textContent=`${grants.filter(g=>g.active).length} active / ${grants.length} total`;grantCount.title='OAuth active means the token row has not expired and has not been revoked.';sessionCount.textContent=`${sessions.filter(s=>s.active).length} active / ${sessions.length} total`;sessionCount.title='Direct API active means the token family has not been revoked and either access or refresh token is still unexpired.';for(const b of chooser.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.kind===kind));for(const b of filters.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.filter===filter))}; - \\const render=()=>{renderControls();const isGrant=kind==='grants';title.textContent=isGrant?'OAuth app sessions':'direct API tokens';hint.textContent=isGrant?'App sign-ins issued by the OAuth flow. Active means the token row has not expired or been revoked. New rows show passkey or password authorization; older rows may show unknown method.':'Token families from com.atproto.server.createSession. Active means the family has not been revoked and still has an unexpired access or refresh token. These are separate from OAuth app sign-ins.';const items=shownItems();if(!items.length)return empty(filter==='usable'?'No active rows.':filter==='ended'?'No inactive rows.':'No entries.');const maxPage=Math.max(0,Math.ceil(items.length/pageSize)-1);if(page>maxPage)page=maxPage;itemsRoot.textContent='';const list=document.createElement('div');list.className='list';for(const item of items.slice(page*pageSize,page*pageSize+pageSize))list.append(card(item));itemsRoot.append(list);renderPager(items.length)}; + \\const renderControls=()=>{grantCount.textContent=`${grants.filter(g=>g.active).length} active / ${grants.length} total`;grantCount.title='OAuth active means the current refresh token has not been revoked; the access token may need refresh.';sessionCount.textContent=`${sessions.filter(s=>s.active).length} active / ${sessions.length} total`;sessionCount.title='Direct API active means the token family has not been revoked and either access or refresh token is still unexpired.';for(const b of chooser.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.kind===kind));for(const b of filters.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.filter===filter))}; + \\const render=()=>{renderControls();const isGrant=kind==='grants';title.textContent=isGrant?'OAuth app sessions':'direct API tokens';hint.textContent=isGrant?'App sign-ins issued by the OAuth flow. Active means the current refresh token has not been revoked; the access token may still need refresh. New rows show passkey or password authorization; older rows may show unknown method.':'Token families from com.atproto.server.createSession. Active means the family has not been revoked and still has an unexpired access or refresh token. These are separate from OAuth app sign-ins.';const items=shownItems();if(!items.length)return empty(filter==='usable'?'No active rows.':filter==='ended'?'No inactive rows.':'No entries.');const maxPage=Math.max(0,Math.ceil(items.length/pageSize)-1);if(page>maxPage)page=maxPage;itemsRoot.textContent='';const list=document.createElement('div');list.className='list';for(const item of items.slice(page*pageSize,page*pageSize+pageSize))list.append(card(item));itemsRoot.append(list);renderPager(items.length)}; \\chooser.addEventListener('click',(e)=>{const b=e.target.closest('button[data-kind]');if(!b)return;kind=b.dataset.kind;page=0;render()}); \\filters.addEventListener('click',(e)=>{const b=e.target.closest('button[data-filter]');if(!b)return;filter=b.dataset.filter;page=0;render()}); \\form.addEventListener('submit',async(e)=>{e.preventDefault();status.className='status';try{status.textContent='loading token rows...';const token=form.token.value.trim();const data=await fail(await fetch('/xrpc/dev.zat.admin.listSessions?active=false&limit=500',{headers:{authorization:`Bearer ${token}`}}),'failed to load token rows');sessions=data.sessions||[];grants=data.oauthGrants||[];chooser.hidden=false;filters.hidden=false;kind='grants';filter=grants.some(g=>g.active)?'usable':grants.length?'ended':'all';page=0;render();status.textContent=''}catch(err){status.className='status error';status.textContent=err.message||String(err)}}); diff --git a/src/storage/store.zig b/src/storage/store.zig index 8d85940..8b4a7ae 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -1074,15 +1074,15 @@ pub fn listOAuthGrantsForAccount(allocator: std.mem.Allocator, did: []const u8, try requireInitialized(); const sql = if (active_only) \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method, - \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active + \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active \\FROM oauth_tokens t \\JOIN accounts a ON a.did = t.did - \\WHERE t.did = ? AND t.revoked_at IS NULL AND t.expires_at > unixepoch() + \\WHERE t.did = ? AND t.revoked_at IS NULL \\ORDER BY t.created_at DESC \\LIMIT ? else \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method, - \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active + \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active \\FROM oauth_tokens t \\JOIN accounts a ON a.did = t.did \\WHERE t.did = ? @@ -1100,15 +1100,15 @@ pub fn listOAuthGrantsForAllAccounts(allocator: std.mem.Allocator, active_only: try requireInitialized(); const sql = if (active_only) \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method, - \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active + \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active \\FROM oauth_tokens t \\JOIN accounts a ON a.did = t.did - \\WHERE t.revoked_at IS NULL AND t.expires_at > unixepoch() + \\WHERE t.revoked_at IS NULL \\ORDER BY t.created_at DESC \\LIMIT ? else \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method, - \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active + \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active \\FROM oauth_tokens t \\JOIN accounts a ON a.did = t.did \\ORDER BY t.created_at DESC @@ -5830,6 +5830,46 @@ test "stores discoverable passkey challenges by oauth request" { try std.testing.expect(try getDiscoverableWebAuthnChallenge(allocator, "req-discoverable") == null); } +test "oauth app sessions stay active after access token expiry until revoked" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + try init(std.Options.debug_io, ":memory:"); + defer close(); + + const account = try createAccount( + allocator, + "oauth-session.test", + "oauth-session@test.com", + "password", + "did:plc:oauthsession", + true, + ); + + try putOAuthToken( + account.did, + "https://client.example.com/oauth-client-metadata.json", + "atproto", + "expired-access", + "current-refresh", + 1, + "passkey", + ); + + const active = try listOAuthGrantsForAccount(allocator, account.did, true, 50); + try std.testing.expectEqual(@as(usize, 1), active.len); + try std.testing.expect(active[0].active); + try std.testing.expectEqualStrings("passkey", active[0].auth_method.?); + + try revokeOAuthToken("current-refresh"); + try std.testing.expectEqual(@as(usize, 0), (try listOAuthGrantsForAccount(allocator, account.did, true, 50)).len); + + const all = try listOAuthGrantsForAccount(allocator, account.did, false, 50); + try std.testing.expectEqual(@as(usize, 1), all.len); + try std.testing.expect(!all[0].active); +} + test "session tokens are durable and refresh rotation invalidates old jti" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); -- 2.51.2