Repository navigation
Expand file tree
/
Copy pathserver-auth.ts
More file actions
113 lines (98 loc) · 4.96 KB
/
Copy pathserver-auth.ts
File metadata and controls
113 lines (98 loc) · 4.96 KB
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
111
112
113
/**
* DEVELOPMENT-ONLY authentication for the embedded persistence route.
*
* The token is NOT a secret: NEXT_PUBLIC_PERSISTENCE_TOKEN is compiled into
* the public browser bundle, so it is fully visible to every visitor and
* provides no confidentiality and no user isolation — anyone who can load the
* page can read and write EVERY learner partition and all documents by
* supplying an arbitrary x-learner-key. Its only purpose is to keep unrelated
* network scanners out of a trusted-network endpoint. Suitable only for
* localhost or trusted-network, single-user deployments. Production must
* replace this module with real session verification and derive learner
* identity from server-controlled claims.
*/
import { createHash, timingSafeEqual } from 'node:crypto';
import type { IncomingMessage } from 'node:http';
import type { AssetPrincipal } from '@openmaic/storage';
import type { RuntimeHttpPrincipal } from '@openmaic/storage/server';
import { createLogger } from '@/lib/logger';
const log = createLogger('PersistenceAuth');
type PersistencePrincipal = RuntimeHttpPrincipal & Partial<Pick<AssetPrincipal, 'key'>>;
/**
* The single asset partition for this deployment shape. Documents have no
* ownership partition; assets get the same treatment until real auth lands.
*
* Exported because the persistence route resolves the asset principal itself,
* server-side, rather than through this module's client-supplied credentials:
* assets are one shared partition by design, so there is nothing per-caller for
* the development authenticator to decide, and routing them through it made
* every asset request fail in a production build that had not opted into it.
*/
export const SHARED_ASSET_PRINCIPAL = 'shared';
/**
* Whether the operator explicitly opted the development authenticator into
* production traffic (PERSISTENCE_ALLOW_INSECURE_DEV_AUTH=true/1). Both the
* startup warning and the runtime gate read the same opt-in through this one
* helper so the two copies of the parsing cannot drift.
*/
function insecureDevAuthOptInEnabled(): boolean {
const optIn = process.env.PERSISTENCE_ALLOW_INSECURE_DEV_AUTH;
return optIn === 'true' || optIn === '1';
}
/**
* The development authenticator must never serve production traffic unless the
* operator explicitly accepts the trade-off. This module provides no user
* isolation, so production defaults to refusing it entirely (returns undefined,
* which callers turn into a 401); PERSISTENCE_ALLOW_INSECURE_DEV_AUTH=true is
* the documented opt-in for trusted-network single-user deployments.
*/
function devAuthenticatorAllowedInCurrentEnvironment(): boolean {
if (process.env.NODE_ENV !== 'production') return true;
return insecureDevAuthOptInEnabled();
}
if (process.env.NODE_ENV === 'production' && insecureDevAuthOptInEnabled()) {
log.warn(
'Persistence is running the development authenticator in production: it provides no user ' +
'isolation, so this endpoint must only be reachable on a trusted network. Replace it with ' +
'real session verification before serving public traffic.',
);
}
function singleHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function secureEqual(left: string, right: string): boolean {
const leftDigest = createHash('sha256').update(left).digest();
const rightDigest = createHash('sha256').update(right).digest();
return timingSafeEqual(leftDigest, rightDigest);
}
function authenticatePersistenceCredentials(
authorization: string | undefined,
learnerKey: string | undefined,
): PersistencePrincipal | undefined {
if (!devAuthenticatorAllowedInCurrentEnvironment()) return undefined;
const token = process.env.PERSISTENCE_DEV_TOKEN;
if (!token || !authorization || !secureEqual(authorization, `Bearer ${token}`)) return undefined;
// Documents are stored without any ownership partition, so assets are
// stored under one shared principal to match: this authenticator provides
// no user isolation either way (the header is client-supplied), and a
// per-header asset partition only meant a converted document's assets
// became unreadable to every other browser the document was shared with.
// The learner key still partitions runtime sessions, which are genuinely
// per-learner state. Production replaces this module with real session
// verification and derives both from server-controlled claims.
return { key: SHARED_ASSET_PRINCIPAL, ...(learnerKey ? { learnerKey } : {}) };
}
export function authenticatePersistenceHeaders(headers: Headers): PersistencePrincipal | undefined {
return authenticatePersistenceCredentials(
headers.get('authorization') ?? undefined,
headers.get('x-learner-key') ?? undefined,
);
}
export async function authenticatePersistenceRequest(
req: IncomingMessage,
): Promise<PersistencePrincipal | undefined> {
return authenticatePersistenceCredentials(
singleHeader(req.headers.authorization),
singleHeader(req.headers['x-learner-key']),
);
}