@ericminassian/platform 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +10 -0
- package/dist/app.js +23 -0
- package/dist/app.js.map +1 -0
- package/dist/auth/cookies.d.ts +10 -0
- package/dist/auth/cookies.js +37 -0
- package/dist/auth/cookies.js.map +1 -0
- package/dist/auth/index.d.ts +22 -0
- package/dist/auth/index.js +217 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/pkce.d.ts +16 -0
- package/dist/auth/pkce.js +32 -0
- package/dist/auth/pkce.js.map +1 -0
- package/dist/auth/tokens.d.ts +17 -0
- package/dist/auth/tokens.js +142 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/bindings.d.ts +13 -0
- package/dist/bindings.js +32 -0
- package/dist/bindings.js.map +1 -0
- package/dist/clients.d.ts +38 -0
- package/dist/clients.js +65 -0
- package/dist/clients.js.map +1 -0
- package/dist/context.d.ts +31 -0
- package/dist/context.js +33 -0
- package/dist/context.js.map +1 -0
- package/dist/db.d.ts +97 -0
- package/dist/db.js +297 -0
- package/dist/db.js.map +1 -0
- package/dist/events.d.ts +9 -0
- package/dist/events.js +28 -0
- package/dist/events.js.map +1 -0
- package/dist/http.d.ts +14 -0
- package/dist/http.js +82 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/queue.d.ts +7 -0
- package/dist/queue.js +50 -0
- package/dist/queue.js.map +1 -0
- package/dist/secrets.d.ts +8 -0
- package/dist/secrets.js +42 -0
- package/dist/secrets.js.map +1 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +232 -0
- package/dist/server.js.map +1 -0
- package/dist/storage.d.ts +30 -0
- package/dist/storage.js +108 -0
- package/dist/storage.js.map +1 -0
- package/dist/telemetry.d.ts +42 -0
- package/dist/telemetry.js +218 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +54 -0
- package/dist/testing.js.map +1 -0
- package/dist/worker.d.ts +73 -0
- package/dist/worker.js +179 -0
- package/dist/worker.js.map +1 -0
- package/package.json +58 -3
- package/src/app.ts +32 -0
- package/src/auth/cookies.ts +39 -0
- package/src/auth/index.ts +246 -0
- package/src/auth/pkce.ts +43 -0
- package/src/auth/tokens.ts +174 -0
- package/src/bindings.ts +35 -0
- package/src/clients.ts +98 -0
- package/src/context.ts +56 -0
- package/src/db.ts +380 -0
- package/src/events.ts +42 -0
- package/src/http.ts +82 -0
- package/src/index.ts +40 -0
- package/src/queue.ts +67 -0
- package/src/secrets.ts +57 -0
- package/src/server.ts +274 -0
- package/src/storage.ts +143 -0
- package/src/telemetry.ts +260 -0
- package/src/testing.ts +73 -0
- package/src/worker.ts +291 -0
- package/README.md +0 -3
package/dist/worker.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker (Lambda Event Function) entry. Apps write:
|
|
3
|
+
*
|
|
4
|
+
* export default defineWorker({
|
|
5
|
+
* queues: { emails: async (msg) => { ... } },
|
|
6
|
+
* events: { 'order.created': async (evt) => { ... } },
|
|
7
|
+
* crons: { nightly: async () => { ... } },
|
|
8
|
+
* });
|
|
9
|
+
*
|
|
10
|
+
* `plat build` generates `export const handler = createWorkerHandler(def)`.
|
|
11
|
+
* One function per stage handles every queue, event subscription, cron and
|
|
12
|
+
* direct invocation; the handler dispatches on the event shape. Each job is
|
|
13
|
+
* auto-instrumented (`job`/`err` telemetry, trace propagation from the
|
|
14
|
+
* producer). SQS batches report partial failures.
|
|
15
|
+
*
|
|
16
|
+
* CONTRACT: used by the CLI build and the control plane. Keep stable.
|
|
17
|
+
*/
|
|
18
|
+
import { gunzipSync } from 'node:zlib';
|
|
19
|
+
import { getBindings, tryBindings } from './bindings.js';
|
|
20
|
+
import { client } from './clients.js';
|
|
21
|
+
import { currentTrace, newContext, runWithContext } from './context.js';
|
|
22
|
+
import { emit, emitError, installConsoleCapture, installFetchInstrumentation, log } from './telemetry.js';
|
|
23
|
+
export function defineWorker(def) {
|
|
24
|
+
return def;
|
|
25
|
+
}
|
|
26
|
+
async function runJob(kind, name, trace, attempt, lambdaCtx, fn) {
|
|
27
|
+
const ctx = newContext(trace);
|
|
28
|
+
ctx.job = `${kind}:${name}`;
|
|
29
|
+
return runWithContext(ctx, async () => {
|
|
30
|
+
const t0 = performance.now();
|
|
31
|
+
const jobCtx = {
|
|
32
|
+
trace: ctx.trace,
|
|
33
|
+
attempt,
|
|
34
|
+
remainingMs: () => lambdaCtx?.getRemainingTimeInMillis?.() ?? Number.POSITIVE_INFINITY,
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
const value = await fn(jobCtx);
|
|
38
|
+
emit('job', { kind, name, status: 'ok', dur: ms(t0), attempt, span: ctx.span });
|
|
39
|
+
return { ok: true, value };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
emitError(error, 'job', { job: `${kind}:${name}` });
|
|
43
|
+
emit('job', { kind, name, status: 'error', dur: ms(t0), attempt, span: ctx.span });
|
|
44
|
+
return { ok: false, error };
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function ms(t0) {
|
|
49
|
+
return Math.round((performance.now() - t0) * 10) / 10;
|
|
50
|
+
}
|
|
51
|
+
function parseBody(raw) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function isSqsEvent(e) {
|
|
60
|
+
const records = e?.Records;
|
|
61
|
+
return Array.isArray(records) && records.length > 0 && records[0]?.eventSource === 'aws:sqs';
|
|
62
|
+
}
|
|
63
|
+
function isEventBridge(e) {
|
|
64
|
+
return (typeof e === 'object' && e !== null && typeof e['detail-type'] === 'string');
|
|
65
|
+
}
|
|
66
|
+
function isDirect(e) {
|
|
67
|
+
const p = e?.__platform;
|
|
68
|
+
return p === 'cron' || p === 'invoke';
|
|
69
|
+
}
|
|
70
|
+
function isLogs(e) {
|
|
71
|
+
return typeof e?.awslogs?.data === 'string';
|
|
72
|
+
}
|
|
73
|
+
/** Lambda handler for a worker definition. */
|
|
74
|
+
export function createWorkerHandler(def) {
|
|
75
|
+
installConsoleCapture();
|
|
76
|
+
installFetchInstrumentation();
|
|
77
|
+
emit('env', { event: 'start', runtime: 'worker', initMs: Math.round(performance.now()) });
|
|
78
|
+
return async (event, context) => {
|
|
79
|
+
const lambdaCtx = context;
|
|
80
|
+
if (isSqsEvent(event))
|
|
81
|
+
return handleSqs(def, event.Records, lambdaCtx);
|
|
82
|
+
if (isLogs(event)) {
|
|
83
|
+
const decoded = JSON.parse(gunzipSync(Buffer.from(event.awslogs.data, 'base64')).toString('utf8'));
|
|
84
|
+
if (decoded.messageType === 'CONTROL_MESSAGE')
|
|
85
|
+
return;
|
|
86
|
+
if (!def.logs) {
|
|
87
|
+
log.warn('worker received a CloudWatch Logs batch but defines no `logs` handler');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const ctx = newContext();
|
|
91
|
+
return runWithContext(ctx, () => def.logs({ logGroup: decoded.logGroup, logStream: decoded.logStream, logEvents: decoded.logEvents ?? [] }, {
|
|
92
|
+
trace: ctx.trace,
|
|
93
|
+
attempt: 1,
|
|
94
|
+
remainingMs: () => lambdaCtx?.getRemainingTimeInMillis?.() ?? Number.POSITIVE_INFINITY,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
if (isEventBridge(event)) {
|
|
98
|
+
const type = event['detail-type'];
|
|
99
|
+
const handler = def.events?.[type];
|
|
100
|
+
if (!handler) {
|
|
101
|
+
log.warn(`no handler for event "${type}"`, { source: event.source });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const env = event.detail;
|
|
105
|
+
const isEnvelope = env !== null && typeof env === 'object' && 'data' in env;
|
|
106
|
+
const r = await runJob('event', type, isEnvelope ? env.trace : undefined, 1, lambdaCtx, (ctx) => handler({ id: event.id, type, detail: isEnvelope ? env.data : event.detail, time: new Date(event.time) }, ctx));
|
|
107
|
+
// Rethrow so Lambda's async retries and the on-failure destination apply.
|
|
108
|
+
if (!r.ok)
|
|
109
|
+
throw r.error;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (isDirect(event)) {
|
|
113
|
+
if (event.__platform === 'cron') {
|
|
114
|
+
const handler = def.crons?.[event.name];
|
|
115
|
+
if (!handler)
|
|
116
|
+
throw new Error(`no cron handler "${event.name}"`);
|
|
117
|
+
const r = await runJob('cron', event.name, event.trace, 1, lambdaCtx, (ctx) => handler(ctx));
|
|
118
|
+
if (!r.ok)
|
|
119
|
+
throw r.error;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const handler = def.invoke?.[event.name];
|
|
123
|
+
if (!handler)
|
|
124
|
+
throw new Error(`no invoke handler "${event.name}"`);
|
|
125
|
+
const r = await runJob('invoke', event.name, event.trace, 1, lambdaCtx, (ctx) => handler(event.payload, ctx));
|
|
126
|
+
if (!r.ok)
|
|
127
|
+
throw r.error;
|
|
128
|
+
return r.value;
|
|
129
|
+
}
|
|
130
|
+
throw new Error('Unrecognized worker event');
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function handleSqs(def, records, lambdaCtx) {
|
|
134
|
+
const queues = tryBindings()?.queues ?? {};
|
|
135
|
+
const byArn = new Map(Object.entries(queues).map(([name, q]) => [q.arn, name]));
|
|
136
|
+
const failures = [];
|
|
137
|
+
for (const record of records) {
|
|
138
|
+
const name = byArn.get(record.eventSourceARN);
|
|
139
|
+
const handler = name ? def.queues?.[name] : undefined;
|
|
140
|
+
if (!name || !handler) {
|
|
141
|
+
emitError(new Error(`no queue handler for ${record.eventSourceARN}`), 'job');
|
|
142
|
+
failures.push({ itemIdentifier: record.messageId });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const attempt = Number(record.attributes?.ApproximateReceiveCount ?? '1');
|
|
146
|
+
const trace = record.messageAttributes?.trace?.stringValue;
|
|
147
|
+
const sentAt = new Date(Number(record.attributes?.SentTimestamp ?? Date.now()));
|
|
148
|
+
const r = await runJob('queue', name, trace, attempt, lambdaCtx, (ctx) => handler({ id: record.messageId, body: parseBody(record.body), attempt, sentAt }, ctx));
|
|
149
|
+
if (!r.ok)
|
|
150
|
+
failures.push({ itemIdentifier: record.messageId });
|
|
151
|
+
}
|
|
152
|
+
return { batchItemFailures: failures };
|
|
153
|
+
}
|
|
154
|
+
/** Invoke another named job on this stage's worker asynchronously. */
|
|
155
|
+
export async function invokeJob(name, payload) {
|
|
156
|
+
const worker = getBindings().worker;
|
|
157
|
+
if (!worker)
|
|
158
|
+
throw new Error('This app has no worker. Add a "worker" (or queues/events/crons) to platform.json.');
|
|
159
|
+
const body = { __platform: 'invoke', name, payload, trace: currentTrace() };
|
|
160
|
+
// `plat dev` / `plat test` run the worker as a local Node process (the
|
|
161
|
+
// emulator's Lambda sandbox has no networking modules) and set this URL.
|
|
162
|
+
const local = process.env.PLATFORM_LOCAL_WORKER_URL;
|
|
163
|
+
if (local) {
|
|
164
|
+
// Fire-and-forget like an async Lambda invoke; failures surface in the worker's logs.
|
|
165
|
+
void fetch(local, { method: 'POST', body: JSON.stringify(body) })
|
|
166
|
+
.then((r) => r.body?.cancel())
|
|
167
|
+
.catch((err) => console.warn(`invokeJob(${name}) could not reach the local worker`, err));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const { InvokeCommand } = await import('@aws-sdk/client-lambda');
|
|
171
|
+
const out = await (await client('lambda')).send(new InvokeCommand({
|
|
172
|
+
FunctionName: worker,
|
|
173
|
+
InvocationType: 'Event',
|
|
174
|
+
Payload: new TextEncoder().encode(JSON.stringify(body)),
|
|
175
|
+
}));
|
|
176
|
+
if (out.FunctionError)
|
|
177
|
+
throw new Error(`invokeJob(${name}) failed: ${out.FunctionError}`);
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker.js","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ericminassian/platform",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Runtime SDK for apps on the platform: db, queues, events, storage, secrets, auth, metrics.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/eric-minassian/platform.git",
|
|
10
|
+
"directory": "packages/sdk"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./server": {
|
|
18
|
+
"types": "./dist/server.d.ts",
|
|
19
|
+
"default": "./dist/server.js"
|
|
20
|
+
},
|
|
21
|
+
"./worker": {
|
|
22
|
+
"types": "./dist/worker.d.ts",
|
|
23
|
+
"default": "./dist/worker.js"
|
|
24
|
+
},
|
|
25
|
+
"./testing": {
|
|
26
|
+
"types": "./dist/testing.d.ts",
|
|
27
|
+
"default": "./dist/testing.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"src"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@aws-sdk/client-cognito-identity-provider": "^3.1169.0",
|
|
39
|
+
"@aws-sdk/client-dynamodb": "^3.1169.0",
|
|
40
|
+
"@aws-sdk/client-eventbridge": "^3.1169.0",
|
|
41
|
+
"@aws-sdk/client-lambda": "^3.1169.0",
|
|
42
|
+
"@aws-sdk/client-s3": "^3.1169.0",
|
|
43
|
+
"@aws-sdk/client-sqs": "^3.1169.0",
|
|
44
|
+
"@aws-sdk/client-ssm": "^3.1169.0",
|
|
45
|
+
"@aws-sdk/lib-dynamodb": "^3.1169.0",
|
|
46
|
+
"@aws-sdk/s3-request-presigner": "^3.1169.0",
|
|
47
|
+
"@ericminassian/platform-core": "0.1.0",
|
|
48
|
+
"jose": "^6.2.17"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@ericminassian/platform-engine": "0.1.0",
|
|
52
|
+
"@types/aws-lambda": "^8.10.164",
|
|
53
|
+
"hono": "^4.13.18"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsc -p tsconfig.build.json",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:integration": "vitest run --config vitest.integration.config.ts"
|
|
60
|
+
}
|
|
6
61
|
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getBindings } from './bindings.ts';
|
|
2
|
+
|
|
3
|
+
export interface AppInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
stage: string;
|
|
6
|
+
host: string;
|
|
7
|
+
url: string;
|
|
8
|
+
revision: string;
|
|
9
|
+
isLocal: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Facts about the running app stage (lazy: read from bindings on access). */
|
|
13
|
+
export const app: AppInfo = {
|
|
14
|
+
get name() {
|
|
15
|
+
return getBindings().app;
|
|
16
|
+
},
|
|
17
|
+
get stage() {
|
|
18
|
+
return getBindings().stage;
|
|
19
|
+
},
|
|
20
|
+
get host() {
|
|
21
|
+
return getBindings().host;
|
|
22
|
+
},
|
|
23
|
+
get url() {
|
|
24
|
+
return getBindings().url;
|
|
25
|
+
},
|
|
26
|
+
get revision() {
|
|
27
|
+
return getBindings().revision;
|
|
28
|
+
},
|
|
29
|
+
get isLocal() {
|
|
30
|
+
return process.env.PLATFORM_LOCAL === '1';
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function parseCookies(header: string | null | undefined): Record<string, string> {
|
|
2
|
+
const out: Record<string, string> = {};
|
|
3
|
+
if (!header) return out;
|
|
4
|
+
for (const part of header.split(';')) {
|
|
5
|
+
const i = part.indexOf('=');
|
|
6
|
+
if (i <= 0) continue;
|
|
7
|
+
const name = part.slice(0, i).trim();
|
|
8
|
+
const raw = part.slice(i + 1).trim();
|
|
9
|
+
if (!name || name in out) continue;
|
|
10
|
+
try {
|
|
11
|
+
out[name] = decodeURIComponent(raw);
|
|
12
|
+
} catch {
|
|
13
|
+
out[name] = raw;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CookieOptions {
|
|
20
|
+
maxAge?: number;
|
|
21
|
+
path?: string;
|
|
22
|
+
httpOnly?: boolean;
|
|
23
|
+
secure?: boolean;
|
|
24
|
+
sameSite?: 'Lax' | 'Strict' | 'None';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function serializeCookie(name: string, value: string, opts: CookieOptions = {}): string {
|
|
28
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
29
|
+
parts.push(`Path=${opts.path ?? '/'}`);
|
|
30
|
+
if (opts.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(opts.maxAge)}`);
|
|
31
|
+
if (opts.httpOnly ?? true) parts.push('HttpOnly');
|
|
32
|
+
if (opts.secure) parts.push('Secure');
|
|
33
|
+
parts.push(`SameSite=${opts.sameSite ?? 'Lax'}`);
|
|
34
|
+
return parts.join('; ');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function clearCookie(name: string, opts: Omit<CookieOptions, 'maxAge'> = {}): string {
|
|
38
|
+
return serializeCookie(name, '', { ...opts, maxAge: 0 });
|
|
39
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTH_COOKIES,
|
|
3
|
+
AUTH_ROUTES,
|
|
4
|
+
AUTH_SCOPES,
|
|
5
|
+
type AuthBindings,
|
|
6
|
+
type Bindings,
|
|
7
|
+
type PlatformUser,
|
|
8
|
+
} from '@ericminassian/platform-core';
|
|
9
|
+
import { getBindings } from '../bindings.ts';
|
|
10
|
+
import { currentContext } from '../context.ts';
|
|
11
|
+
import { log } from '../telemetry.ts';
|
|
12
|
+
import { clearCookie, parseCookies, serializeCookie } from './cookies.ts';
|
|
13
|
+
import { challengeFor, createVerifier, decodeFlow, encodeFlow, randomState, safeRedirect } from './pkce.ts';
|
|
14
|
+
import {
|
|
15
|
+
exchangeCode,
|
|
16
|
+
isExpiredError,
|
|
17
|
+
localSignIn,
|
|
18
|
+
refreshTokens,
|
|
19
|
+
type Tokens,
|
|
20
|
+
verifyToken,
|
|
21
|
+
} from './tokens.ts';
|
|
22
|
+
|
|
23
|
+
export interface Auth {
|
|
24
|
+
/** The signed-in user for this request, or null. */
|
|
25
|
+
getUser(request: Request): Promise<PlatformUser | null>;
|
|
26
|
+
/** Like getUser but throws a 401 Response when signed out. */
|
|
27
|
+
requireUser(request: Request): Promise<PlatformUser>;
|
|
28
|
+
/** Path to start sign-in, returning to `redirect` afterwards. */
|
|
29
|
+
loginUrl(redirect?: string): string;
|
|
30
|
+
logoutUrl(redirect?: string): string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SESSION_MAX_AGE = 30 * 24 * 3600;
|
|
34
|
+
const FLOW_MAX_AGE = 600;
|
|
35
|
+
|
|
36
|
+
function authBindings(): AuthBindings {
|
|
37
|
+
const a = getBindings().auth;
|
|
38
|
+
if (!a) throw new Error('Auth is not enabled for this app. Add `"auth": true` to platform.json.');
|
|
39
|
+
return a;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function secure(b: Bindings): boolean {
|
|
43
|
+
return b.url.startsWith('https:');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function sessionCookies(b: Bindings, tokens: Tokens): string[] {
|
|
47
|
+
const opts = { maxAge: SESSION_MAX_AGE, secure: secure(b) };
|
|
48
|
+
const out = [serializeCookie(AUTH_COOKIES.id, tokens.idToken, opts)];
|
|
49
|
+
if (tokens.refreshToken) out.push(serializeCookie(AUTH_COOKIES.refresh, tokens.refreshToken, opts));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clearSessionCookies(b: Bindings): string[] {
|
|
54
|
+
return [
|
|
55
|
+
clearCookie(AUTH_COOKIES.id, { secure: secure(b) }),
|
|
56
|
+
clearCookie(AUTH_COOKIES.refresh, { secure: secure(b) }),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function bearer(request: Request): string | undefined {
|
|
61
|
+
const h = request.headers.get('authorization');
|
|
62
|
+
const m = h ? /^Bearer\s+(.+)$/i.exec(h) : null;
|
|
63
|
+
return m?.[1];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function resolveUser(request: Request): Promise<PlatformUser | null> {
|
|
67
|
+
const ctx = currentContext();
|
|
68
|
+
if (ctx && ctx.user !== undefined) return ctx.user;
|
|
69
|
+
const b = getBindings();
|
|
70
|
+
const a = authBindings();
|
|
71
|
+
const cookies = parseCookies(request.headers.get('cookie'));
|
|
72
|
+
const idToken = cookies[AUTH_COOKIES.id] ?? bearer(request);
|
|
73
|
+
const refresh = cookies[AUTH_COOKIES.refresh];
|
|
74
|
+
let user: PlatformUser | null = null;
|
|
75
|
+
let expired = !idToken;
|
|
76
|
+
if (idToken) {
|
|
77
|
+
try {
|
|
78
|
+
user = await verifyToken(a, idToken);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
expired = isExpiredError(e);
|
|
81
|
+
if (!expired) log.debug('auth: rejected session token', { reason: (e as Error).message });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!user && expired && refresh) {
|
|
85
|
+
try {
|
|
86
|
+
const tokens = await refreshTokens(a, refresh);
|
|
87
|
+
user = await verifyToken(a, tokens.idToken);
|
|
88
|
+
ctx?.cookies.push(...sessionCookies(b, tokens));
|
|
89
|
+
} catch (e) {
|
|
90
|
+
log.debug('auth: refresh failed', { reason: (e as Error).message });
|
|
91
|
+
ctx?.cookies.push(...clearSessionCookies(b));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (ctx) ctx.user = user;
|
|
95
|
+
return user;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function unauthorized(): Response {
|
|
99
|
+
return Response.json(
|
|
100
|
+
{ error: { code: 'unauthorized', message: 'Sign in required', loginUrl: AUTH_ROUTES.login } },
|
|
101
|
+
{ status: 401, headers: { 'cache-control': 'no-store' } },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const auth: Auth = {
|
|
106
|
+
getUser: (request) => resolveUser(request),
|
|
107
|
+
async requireUser(request) {
|
|
108
|
+
const user = await resolveUser(request);
|
|
109
|
+
if (!user) throw unauthorized();
|
|
110
|
+
return user;
|
|
111
|
+
},
|
|
112
|
+
loginUrl: (redirect = '/') => `${AUTH_ROUTES.login}?redirect=${encodeURIComponent(safeRedirect(redirect))}`,
|
|
113
|
+
logoutUrl: (redirect = '/') =>
|
|
114
|
+
`${AUTH_ROUTES.logout}?redirect=${encodeURIComponent(safeRedirect(redirect))}`,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `auth.required`: redirect signed-out browsers to sign-in and 401 everything
|
|
119
|
+
* else. Returns a Response to short-circuit, or null to continue.
|
|
120
|
+
*/
|
|
121
|
+
export async function authGate(request: Request): Promise<Response | null> {
|
|
122
|
+
const a = getBindings().auth;
|
|
123
|
+
if (!a?.required) return null;
|
|
124
|
+
if (await resolveUser(request)) return null;
|
|
125
|
+
const url = new URL(request.url);
|
|
126
|
+
const wantsHtml = request.method === 'GET' && (request.headers.get('accept') ?? '').includes('text/html');
|
|
127
|
+
if (wantsHtml) return redirect(auth.loginUrl(url.pathname + url.search));
|
|
128
|
+
return unauthorized();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function redirect(location: string, cookies: string[] = [], status = 302): Response {
|
|
132
|
+
const headers = new Headers({ location, 'cache-control': 'no-store' });
|
|
133
|
+
for (const c of cookies) headers.append('set-cookie', c);
|
|
134
|
+
return new Response(null, { status, headers });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------- /__platform/auth/*
|
|
138
|
+
|
|
139
|
+
/** Handles the auth routes; returns null for other paths. */
|
|
140
|
+
export async function handleAuthRoute(request: Request): Promise<Response | null> {
|
|
141
|
+
const url = new URL(request.url);
|
|
142
|
+
const route = Object.values(AUTH_ROUTES).find((p) => p === url.pathname);
|
|
143
|
+
if (!route) return null;
|
|
144
|
+
const b = getBindings();
|
|
145
|
+
const a = b.auth;
|
|
146
|
+
if (!a)
|
|
147
|
+
return Response.json({ error: { code: 'not_found', message: 'Auth is not enabled' } }, { status: 404 });
|
|
148
|
+
|
|
149
|
+
if (route === AUTH_ROUTES.me) {
|
|
150
|
+
const user = await resolveUser(request);
|
|
151
|
+
return user ? Response.json(user, { headers: { 'cache-control': 'no-store' } }) : unauthorized();
|
|
152
|
+
}
|
|
153
|
+
if (route === AUTH_ROUTES.logout) {
|
|
154
|
+
const back = safeRedirect(url.searchParams.get('redirect'));
|
|
155
|
+
if (a.mode === 'local') return redirect(back, clearSessionCookies(b));
|
|
156
|
+
const target = new URL(`${a.loginUrl.replace(/\/$/, '')}/logout`);
|
|
157
|
+
target.searchParams.set('client_id', a.clientId);
|
|
158
|
+
target.searchParams.set('logout_uri', `${b.url}/`);
|
|
159
|
+
return redirect(target.toString(), clearSessionCookies(b));
|
|
160
|
+
}
|
|
161
|
+
if (a.mode === 'local') return localRoute(request, b, a, route);
|
|
162
|
+
|
|
163
|
+
const callbackUrl = `${b.url}${AUTH_ROUTES.callback}`;
|
|
164
|
+
if (route === AUTH_ROUTES.login) {
|
|
165
|
+
const verifier = createVerifier();
|
|
166
|
+
const state = randomState();
|
|
167
|
+
const flow = encodeFlow({ v: verifier, s: state, r: safeRedirect(url.searchParams.get('redirect')) });
|
|
168
|
+
const target = new URL(`${a.loginUrl.replace(/\/$/, '')}/oauth2/authorize`);
|
|
169
|
+
target.search = new URLSearchParams({
|
|
170
|
+
client_id: a.clientId,
|
|
171
|
+
response_type: 'code',
|
|
172
|
+
scope: AUTH_SCOPES.join(' '),
|
|
173
|
+
redirect_uri: callbackUrl,
|
|
174
|
+
state,
|
|
175
|
+
code_challenge: challengeFor(verifier),
|
|
176
|
+
code_challenge_method: 'S256',
|
|
177
|
+
}).toString();
|
|
178
|
+
return redirect(target.toString(), [
|
|
179
|
+
serializeCookie(AUTH_COOKIES.flow, flow, {
|
|
180
|
+
maxAge: FLOW_MAX_AGE,
|
|
181
|
+
secure: secure(b),
|
|
182
|
+
path: '/__platform/auth',
|
|
183
|
+
}),
|
|
184
|
+
]);
|
|
185
|
+
}
|
|
186
|
+
// callback
|
|
187
|
+
const flow = decodeFlow(parseCookies(request.headers.get('cookie'))[AUTH_COOKIES.flow]);
|
|
188
|
+
const code = url.searchParams.get('code');
|
|
189
|
+
const clearFlow = clearCookie(AUTH_COOKIES.flow, { secure: secure(b), path: '/__platform/auth' });
|
|
190
|
+
if (url.searchParams.get('error')) {
|
|
191
|
+
return redirect(flow?.r ?? '/', [clearFlow]);
|
|
192
|
+
}
|
|
193
|
+
if (!flow || !code || url.searchParams.get('state') !== flow.s) {
|
|
194
|
+
return new Response('Sign-in expired or was tampered with. Please try again.', {
|
|
195
|
+
status: 400,
|
|
196
|
+
headers: { 'set-cookie': clearFlow, 'cache-control': 'no-store' },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const tokens = await exchangeCode(a, code, flow.v, callbackUrl);
|
|
200
|
+
return redirect(flow.r, [...sessionCookies(b, tokens), clearFlow]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function localRoute(request: Request, b: Bindings, a: AuthBindings, route: string): Promise<Response> {
|
|
204
|
+
const url = new URL(request.url);
|
|
205
|
+
if (route === AUTH_ROUTES.login && request.method === 'POST') {
|
|
206
|
+
const form = await request.formData();
|
|
207
|
+
const email = String(form.get('email') ?? '')
|
|
208
|
+
.trim()
|
|
209
|
+
.toLowerCase();
|
|
210
|
+
const name = String(form.get('name') ?? '').trim();
|
|
211
|
+
const back = safeRedirect(String(form.get('redirect') ?? '/'));
|
|
212
|
+
if (!/^[^\s@]+@[^\s@]+$/.test(email)) return html(loginPage(b, back, 'Enter an email address.'), 400);
|
|
213
|
+
const tokens = await localSignIn(a, email, name || undefined);
|
|
214
|
+
return redirect(back, sessionCookies(b, tokens), 303);
|
|
215
|
+
}
|
|
216
|
+
if (route === AUTH_ROUTES.login) return html(loginPage(b, safeRedirect(url.searchParams.get('redirect'))));
|
|
217
|
+
// No hosted UI locally; callback is never used.
|
|
218
|
+
return redirect('/');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function html(body: string, status = 200): Response {
|
|
222
|
+
return new Response(body, {
|
|
223
|
+
status,
|
|
224
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function escapeHtml(s: string): string {
|
|
229
|
+
return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function loginPage(b: Bindings, back: string, error?: string): string {
|
|
233
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
234
|
+
<title>Sign in · ${escapeHtml(b.app)} (local)</title>
|
|
235
|
+
<style>body{font:16px system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;background:#f6f7f9;color:#111}
|
|
236
|
+
form{background:#fff;padding:2rem;border-radius:12px;box-shadow:0 1px 3px #0002;display:grid;gap:.75rem;min-width:18rem}
|
|
237
|
+
input,button{font:inherit;padding:.6rem .75rem;border-radius:8px;border:1px solid #ccd}button{background:#111;color:#fff;border:0;cursor:pointer}
|
|
238
|
+
small{color:#667}.err{color:#b00}</style></head><body>
|
|
239
|
+
<form method="post" action="${AUTH_ROUTES.login}">
|
|
240
|
+
<strong>Sign in to ${escapeHtml(b.app)}</strong><small>Local development: any email works. Users live in the AWS Emulator.</small>
|
|
241
|
+
${error ? `<span class="err">${escapeHtml(error)}</span>` : ''}
|
|
242
|
+
<input name="email" type="email" placeholder="you@example.com" required autofocus>
|
|
243
|
+
<input name="name" placeholder="Name (optional)">
|
|
244
|
+
<input type="hidden" name="redirect" value="${escapeHtml(back)}">
|
|
245
|
+
<button type="submit">Sign in</button></form></body></html>`;
|
|
246
|
+
}
|
package/src/auth/pkce.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** RFC 7636 code verifier (43-128 chars of unreserved characters). */
|
|
4
|
+
export function createVerifier(): string {
|
|
5
|
+
return randomBytes(32).toString('base64url');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function challengeFor(verifier: string): string {
|
|
9
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function randomState(): string {
|
|
13
|
+
return randomBytes(16).toString('base64url');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FlowState {
|
|
17
|
+
/** PKCE verifier. */
|
|
18
|
+
v: string;
|
|
19
|
+
/** OAuth state. */
|
|
20
|
+
s: string;
|
|
21
|
+
/** Path to return to after sign-in. */
|
|
22
|
+
r: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function encodeFlow(f: FlowState): string {
|
|
26
|
+
return Buffer.from(JSON.stringify(f)).toString('base64url');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function decodeFlow(raw: string | undefined): FlowState | null {
|
|
30
|
+
if (!raw) return null;
|
|
31
|
+
try {
|
|
32
|
+
const f = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as FlowState;
|
|
33
|
+
return typeof f.v === 'string' && typeof f.s === 'string' && typeof f.r === 'string' ? f : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Only same-origin absolute paths are allowed as post-login destinations. */
|
|
40
|
+
export function safeRedirect(target: string | null | undefined): string {
|
|
41
|
+
if (!target?.startsWith('/') || target.startsWith('//') || target.includes('\\')) return '/';
|
|
42
|
+
return target;
|
|
43
|
+
}
|