rojects/source/auth/TokenUpdater.ts

112 lines
3.3 KiB
TypeScript
Raw Normal View History

import { EventSlot } from '../library-ts/browser/events/EventSlot.js';
import { ActivityAnalyser } from '../library-ts/browser/dom/ActivityAnalyser.js';
// Central token-lifecycle owner for the browser session. Runs periodically and on
// user activity, keeping the session's cookies fresh via a cheap authenticated ping.
// Uses Web Locks to elect exactly one leader tab; other tabs follow state via
// BroadcastChannel. The actual refresh decision is server-authoritative (see
// PROACTIVE_REFRESH_MARGIN_SEC in auth-connector's jwtMiddleware) — this class
// never inspects or compares token expiry itself.
export type AuthState = 'valid' | 'refreshing' | 'expired' | 'network-error';
type AuthChannelMessage =
| { type: 'state'; value: AuthState }
| { type: 'request-state' };
const CHANNEL_NAME = 'roject-auth';
const LOCK_NAME = 'roject-token-updater-leader';
export class TokenUpdater
{
static readonly CHECK_INTERVAL_MS = 5 * 60 * 1000;
readonly onStateChanged = new EventSlot<AuthState>();
private _state: AuthState = 'valid';
get state(): AuthState { return this._state; }
private readonly _activity = new ActivityAnalyser();
private _checking = false;
private _isLeader = false;
private readonly _channel = new BroadcastChannel( CHANNEL_NAME );
start(): void
{
this._channel.addEventListener( 'message', ( e: MessageEvent ) =>
{
const msg = e.data as AuthChannelMessage;
if ( this._isLeader )
{
if ( msg.type === 'request-state' )
this._channel.postMessage( { type: 'state', value: this._state } );
return;
}
if ( msg.type === 'state' ) this._setState( msg.value );
} );
if ( !( 'locks' in navigator ) )
{
this._becomeLeader();
return;
}
// Ask the current leader (if any) for its state so this tab syncs immediately.
this._channel.postMessage( { type: 'request-state' } );
// Queue for the exclusive lock. The first tab gets it immediately; subsequent
// tabs wait silently (listening via BroadcastChannel) until the current holder
// closes, then automatically become the new leader.
void navigator.locks.request( LOCK_NAME, async () =>
{
this._becomeLeader();
await new Promise<void>( () => {} ); // hold the lock until tab closes
} );
}
private _becomeLeader(): void
{
this._isLeader = true;
this._activity.start();
this._activity.onActive.addListener( () => this._check() );
setInterval( () => this._check(), TokenUpdater.CHECK_INTERVAL_MS );
void this._check();
}
private async _check(): Promise<void>
{
if ( this._checking ) return;
this._checking = true;
this._setState( 'refreshing' );
try
{
const res = await fetch( '/api/auth/me' );
if ( res.ok ) this._setState( 'valid' );
else if ( res.status === 401 ) this._setState( 'expired' );
else this._setState( 'network-error' );
}
catch
{
this._setState( 'network-error' );
}
finally
{
this._checking = false;
}
}
private _setState( state: AuthState ): void
{
if ( this._state === state ) return;
this._state = state;
this.onStateChanged.dispatch( state );
if ( this._isLeader )
this._channel.postMessage( { type: 'state', value: state } );
}
}