All files / realm/blong-access/adapter/db accessSessionRestore.ts

27.27% Statements 30/110
100% Branches 1/1
0% Functions 0/1
27.27% Lines 30/110

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 1111x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 7x                                                                                                                                                                 7x  
import {type IMeta, handler} from '@feasibleone/blong';
 
import * as account from './account.ts';
 
type KnexQb = any;
 
/** Default inactivity timeout (seconds) when the caller does not supply one. */
const DEFAULT_INACTIVITY_TIMEOUT = 30 * 60;
 
/**
 * `access.session.restore` — validate an opaque restore-cookie handle.
 *
 * The cookie carries a random opaque handle (never the JWT).  Only its
 * SHA-256 hex digest is stored on the session row (`cookieHash`), so a leaked
 * database dump cannot be replayed as cookies.  The restore endpoint
 * (`login.token.restore`) calls this to exchange the handle for a live
 * session; the handle is then rotated (new handle + digest) so a captured
 * cookie cannot be reused.
 *
 * Validity rules mirror `access.session.verify` (exists / not revoked / not
 * expired / not inactive / user active / `accessLogin` eligibility), keyed by
 * `cookieHash` instead of `sessionId`.  An ineligible session is reported as
 * `{valid: false, reason: 'userInactive'}` / `'loginNotAllowed'` rather than
 * throwing (the restore endpoint maps those to its own 401 errors).
 *
 * Wire: `access.session.restore`.
 */
export default handler(() => ({
    async accessSessionRestore(
        params: {
            cookieHash: string;
            touch?: boolean;
            /** Inactivity timeout in seconds (default 30 min). */
            inactivityTimeout?: number;
            /** New cookie-handle digest to rotate to (one-time use cookies). */
            newCookieHash?: string;
        },
        $meta: IMeta,
    ): Promise<{
        valid: boolean;
        reason?:
            | 'notFound'
            | 'revoked'
            | 'expired'
            | 'inactive'
            | 'userInactive'
            | 'loginNotAllowed';
        sessionId?: string;
        userId?: string;
        credentialId?: number;
    }> {
        const qb: KnexQb = this.config?.context?.queryBuilder;
        if (!qb) throw new Error('Database not available');
        if (!params.cookieHash) return {valid: false, reason: 'notFound'};
        const session = (await qb('access_session')
            .select('access_session.*', 'u.isActive as userIsActive')
            .leftJoin('access_user as u', 'u.userId', 'access_session.userId')
            .where('access_session.cookieHash', params.cookieHash)
            .first()) as
            | {
                  sessionId: Buffer;
                  userId: Buffer;
                  credentialId: number;
                  isRevoked: number | boolean;
                  expiresAt: Date;
                  lastActivityAt: Date;
                  userIsActive?: boolean | null;
              }
            | undefined;
        if (!session) return {valid: false, reason: 'notFound'};
        const now = Date.now();
        const inactivityTimeout = (params.inactivityTimeout ?? DEFAULT_INACTIVITY_TIMEOUT) * 1000;
        // MySQL rounds fractional seconds UP on datetime columns — add tolerance.
        const elapsed = now - new Date(session.lastActivityAt).getTime() + 1000;
        if (session.isRevoked) return {valid: false, reason: 'revoked'};
        if (new Date(session.expiresAt).getTime() <= now) return {valid: false, reason: 'expired'};
        if (elapsed > inactivityTimeout) {
            return {valid: false, reason: 'inactive'};
        }
        // Login-eligibility on the restore gate (mirrors `access.session.verify`):
        // a deactivated user — or one whose roles no longer grant the
        // `accessLogin` action — cannot resume the session via the cookie.
        if (!session.userIsActive) return {valid: false, reason: 'userInactive'};
        const loginAction = (await qb
            .select('res.resourceName')
            .from('core_resource as res')
            .join('core_path as p', 'p.destinationId', 'res.resourceId')
            .where('p.originId', session.userId)
            .where('p.pathType', 'access.effectiveAction')
            .where('res.resourceName', 'accessLogin')
            .first()) as {resourceName: string} | undefined;
        if (!loginAction) return {valid: false, reason: 'loginNotAllowed'};
        if (params.touch || params.newCookieHash) {
            await qb('access_session')
                .where('sessionId', session.sessionId)
                .update({
                    ...(params.touch && {lastActivityAt: new Date(now)}),
                    ...(params.newCookieHash && {cookieHash: params.newCookieHash}),
                });
        }
        return {
            valid: true,
            sessionId: account.bufToUuid(session.sessionId),
            // Dashed UUID of the stored bytes — directly usable by
            // `access.permission.list` (`uuidBuf` round-trips to the DB order).
            userId: account.bufToUuid(session.userId),
            credentialId: session.credentialId,
        };
    },
}));