112 lines
3.3 KiB
TypeScript
112 lines
3.3 KiB
TypeScript
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 } );
|
|
}
|
|
}
|