···206206 \\<button class="choice" type="button" data-kind="sessions" aria-pressed="false"><span>direct API tokens</span><strong id="session-count">0</strong></button>
207207 \\</div>
208208 \\<div class="filters" id="filters" hidden>
209209- \\<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>
209209+ \\<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>
210210 \\<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>
211211 \\<button class="filter" type="button" data-filter="all" aria-pressed="false" title="Show active and inactive rows together.">all</button>
212212 \\</div>
···639639 \\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'})}};
640640 \\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};
641641 \\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)};
642642- \\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.';
643643- \\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.';
642642+ \\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.';
643643+ \\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.';
644644 \\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};
645645 \\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};
646646 \\const oauthAuthMethod=(g)=>g.authMethod==='passkey'?'passkey':g.authMethod==='password'?'password':'unknown method';
647647- \\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};
647647+ \\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};
648648 \\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)};
649649- \\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))};
650650- \\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)};
649649+ \\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))};
650650+ \\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)};
651651 \\chooser.addEventListener('click',(e)=>{const b=e.target.closest('button[data-kind]');if(!b)return;kind=b.dataset.kind;page=0;render()});
652652 \\filters.addEventListener('click',(e)=>{const b=e.target.closest('button[data-filter]');if(!b)return;filter=b.dataset.filter;page=0;render()});
653653 \\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
···10741074 try requireInitialized();
10751075 const sql = if (active_only)
10761076 \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method,
10771077- \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active
10771077+ \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active
10781078 \\FROM oauth_tokens t
10791079 \\JOIN accounts a ON a.did = t.did
10801080- \\WHERE t.did = ? AND t.revoked_at IS NULL AND t.expires_at > unixepoch()
10801080+ \\WHERE t.did = ? AND t.revoked_at IS NULL
10811081 \\ORDER BY t.created_at DESC
10821082 \\LIMIT ?
10831083 else
10841084 \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method,
10851085- \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active
10851085+ \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active
10861086 \\FROM oauth_tokens t
10871087 \\JOIN accounts a ON a.did = t.did
10881088 \\WHERE t.did = ?
···11001100 try requireInitialized();
11011101 const sql = if (active_only)
11021102 \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method,
11031103- \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active
11031103+ \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active
11041104 \\FROM oauth_tokens t
11051105 \\JOIN accounts a ON a.did = t.did
11061106- \\WHERE t.revoked_at IS NULL AND t.expires_at > unixepoch()
11061106+ \\WHERE t.revoked_at IS NULL
11071107 \\ORDER BY t.created_at DESC
11081108 \\LIMIT ?
11091109 else
11101110 \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, t.auth_method,
11111111- \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active
11111111+ \\ CASE WHEN t.revoked_at IS NULL THEN 1 ELSE 0 END AS active
11121112 \\FROM oauth_tokens t
11131113 \\JOIN accounts a ON a.did = t.did
11141114 \\ORDER BY t.created_at DESC
···5828582858295829 try deleteDiscoverableWebAuthnChallenge("req-discoverable");
58305830 try std.testing.expect(try getDiscoverableWebAuthnChallenge(allocator, "req-discoverable") == null);
58315831+}
58325832+58335833+test "oauth app sessions stay active after access token expiry until revoked" {
58345834+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
58355835+ defer arena.deinit();
58365836+ const allocator = arena.allocator();
58375837+58385838+ try init(std.Options.debug_io, ":memory:");
58395839+ defer close();
58405840+58415841+ const account = try createAccount(
58425842+ allocator,
58435843+ "oauth-session.test",
58445844+ "oauth-session@test.com",
58455845+ "password",
58465846+ "did:plc:oauthsession",
58475847+ true,
58485848+ );
58495849+58505850+ try putOAuthToken(
58515851+ account.did,
58525852+ "https://client.example.com/oauth-client-metadata.json",
58535853+ "atproto",
58545854+ "expired-access",
58555855+ "current-refresh",
58565856+ 1,
58575857+ "passkey",
58585858+ );
58595859+58605860+ const active = try listOAuthGrantsForAccount(allocator, account.did, true, 50);
58615861+ try std.testing.expectEqual(@as(usize, 1), active.len);
58625862+ try std.testing.expect(active[0].active);
58635863+ try std.testing.expectEqualStrings("passkey", active[0].auth_method.?);
58645864+58655865+ try revokeOAuthToken("current-refresh");
58665866+ try std.testing.expectEqual(@as(usize, 0), (try listOAuthGrantsForAccount(allocator, account.did, true, 50)).len);
58675867+58685868+ const all = try listOAuthGrantsForAccount(allocator, account.did, false, 50);
58695869+ try std.testing.expectEqual(@as(usize, 1), all.len);
58705870+ try std.testing.expect(!all[0].active);
58315871}
5832587258335873test "session tokens are durable and refresh rotation invalidates old jti" {