atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

Allow OAuth refresh after access expiry

zzstoatzz (Jun 9, 2026, 12:39 PM -0500) 7924b727 04d2b26f

+54 -15
+2 -3
src/atproto/oauth.zig
··· 446 446 log.err("oauth refresh invalid_grant: refresh token not found token_len={d} client={s}\n", .{ refresh.len, client_id }); 447 447 return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); 448 448 }; 449 - const ts = now(); 450 449 const client_id_match = std.mem.eql(u8, token_row.client_id, client_id); 451 - if (token_row.revoked or token_row.expires_at < ts or !client_id_match) { 452 - 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 }); 450 + if (token_row.revoked or !client_id_match) { 451 + 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 }); 453 452 return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); 454 453 } 455 454 const account = (try store.findAccount(allocator, token_row.did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found");
+6 -6
src/internal/passkeys.zig
··· 206 206 \\<button class="choice" type="button" data-kind="sessions" aria-pressed="false"><span>direct API tokens</span><strong id="session-count">0</strong></button> 207 207 \\</div> 208 208 \\<div class="filters" id="filters" hidden> 209 - \\<button class="filter" type="button" data-filter="usable" aria-pressed="true" title="Show rows that can currently authenticate requests. For OAuth rows: not expired and not revoked. For direct API token families: not revoked and either access or refresh token is still unexpired.">active</button> 209 + \\<button class="filter" type="button" data-filter="usable" aria-pressed="true" title="Show rows that can continue authenticating. For OAuth app sessions: current refresh token is not revoked. For direct API token families: not revoked and either access or refresh token is still unexpired.">active</button> 210 210 \\<button class="filter" type="button" data-filter="ended" aria-pressed="false" title="Show rows that cannot currently authenticate requests because they are expired or revoked.">inactive</button> 211 211 \\<button class="filter" type="button" data-filter="all" aria-pressed="false" title="Show active and inactive rows together.">all</button> 212 212 \\</div> ··· 639 639 \\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'})}}; 640 640 \\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}; 641 641 \\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)}; 642 - \\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.'; 643 - \\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.'; 642 + \\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.'; 643 + \\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.'; 644 644 \\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}; 645 645 \\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}; 646 646 \\const oauthAuthMethod=(g)=>g.authMethod==='passkey'?'passkey':g.authMethod==='password'?'password':'unknown method'; 647 - \\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}; 647 + \\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}; 648 648 \\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)}; 649 - \\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))}; 650 - \\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)}; 649 + \\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))}; 650 + \\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)}; 651 651 \\chooser.addEventListener('click',(e)=>{const b=e.target.closest('button[data-kind]');if(!b)return;kind=b.dataset.kind;page=0;render()}); 652 652 \\filters.addEventListener('click',(e)=>{const b=e.target.closest('button[data-filter]');if(!b)return;filter=b.dataset.filter;page=0;render()}); 653 653 \\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)}});
+46 -6
src/storage/store.zig
··· 1074 1074 try requireInitialized(); 1075 1075 const sql = if (active_only) 1076 1076 \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method, 1077 - \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active 1077 + \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active 1078 1078 \\FROM oauth_tokens t 1079 1079 \\JOIN accounts a ON a.did = t.did 1080 - \\WHERE t.did = ? AND t.revoked_at IS NULL AND t.expires_at > unixepoch() 1080 + \\WHERE t.did = ? AND t.revoked_at IS NULL 1081 1081 \\ORDER BY t.created_at DESC 1082 1082 \\LIMIT ? 1083 1083 else 1084 1084 \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method, 1085 - \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active 1085 + \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active 1086 1086 \\FROM oauth_tokens t 1087 1087 \\JOIN accounts a ON a.did = t.did 1088 1088 \\WHERE t.did = ? ··· 1100 1100 try requireInitialized(); 1101 1101 const sql = if (active_only) 1102 1102 \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method, 1103 - \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active 1103 + \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active 1104 1104 \\FROM oauth_tokens t 1105 1105 \\JOIN accounts a ON a.did = t.did 1106 - \\WHERE t.revoked_at IS NULL AND t.expires_at > unixepoch() 1106 + \\WHERE t.revoked_at IS NULL 1107 1107 \\ORDER BY t.created_at DESC 1108 1108 \\LIMIT ? 1109 1109 else 1110 1110 \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method, 1111 - \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active 1111 + \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active 1112 1112 \\FROM oauth_tokens t 1113 1113 \\JOIN accounts a ON a.did = t.did 1114 1114 \\ORDER BY t.created_at DESC ··· 5828 5828 5829 5829 try deleteDiscoverableWebAuthnChallenge("req-discoverable"); 5830 5830 try std.testing.expect(try getDiscoverableWebAuthnChallenge(allocator, "req-discoverable") == null); 5831 + } 5832 + 5833 + test "oauth app sessions stay active after access token expiry until revoked" { 5834 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 5835 + defer arena.deinit(); 5836 + const allocator = arena.allocator(); 5837 + 5838 + try init(std.Options.debug_io, ":memory:"); 5839 + defer close(); 5840 + 5841 + const account = try createAccount( 5842 + allocator, 5843 + "oauth-session.test", 5844 + "oauth-session@test.com", 5845 + "password", 5846 + "did:plc:oauthsession", 5847 + true, 5848 + ); 5849 + 5850 + try putOAuthToken( 5851 + account.did, 5852 + "https://client.example.com/oauth-client-metadata.json", 5853 + "atproto", 5854 + "expired-access", 5855 + "current-refresh", 5856 + 1, 5857 + "passkey", 5858 + ); 5859 + 5860 + const active = try listOAuthGrantsForAccount(allocator, account.did, true, 50); 5861 + try std.testing.expectEqual(@as(usize, 1), active.len); 5862 + try std.testing.expect(active[0].active); 5863 + try std.testing.expectEqualStrings("passkey", active[0].auth_method.?); 5864 + 5865 + try revokeOAuthToken("current-refresh"); 5866 + try std.testing.expectEqual(@as(usize, 0), (try listOAuthGrantsForAccount(allocator, account.did, true, 50)).len); 5867 + 5868 + const all = try listOAuthGrantsForAccount(allocator, account.did, false, 50); 5869 + try std.testing.expectEqual(@as(usize, 1), all.len); 5870 + try std.testing.expect(!all[0].active); 5831 5871 } 5832 5872 5833 5873 test "session tokens are durable and refresh rotation invalidates old jti" {