@fabrika/proxy-core 0.0.4 → 0.0.5
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/package.json +3 -3
- package/src/service.ts +63 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/proxy-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"test": "bun test"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@fabrika/auth-core": "^0.0.
|
|
30
|
-
"@fabrika/proxy-contract": "^0.0.
|
|
29
|
+
"@fabrika/auth-core": "^0.0.5",
|
|
30
|
+
"@fabrika/proxy-contract": "^0.0.5",
|
|
31
31
|
"jose": "^5.9.0"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/service.ts
CHANGED
|
@@ -123,7 +123,8 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
|
|
|
123
123
|
// The handoff callback is checked BEFORE the gates: it is how a browser becomes able to
|
|
124
124
|
// satisfy one, so it cannot itself be behind one. Because Caddy returns a non-2xx auth
|
|
125
125
|
// response verbatim, answering 302 + Set-Cookie here reaches the client and never the app.
|
|
126
|
-
const
|
|
126
|
+
const isCallback = forwarded.url.pathname === AUTH_CALLBACK_PATH
|
|
127
|
+
const decision = isCallback
|
|
127
128
|
? await authorizer.authorizeCallback(resolved, forwarded)
|
|
128
129
|
: await authorizer.authorize(resolved, forwarded)
|
|
129
130
|
const fields: LogFields = {
|
|
@@ -145,7 +146,7 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
|
|
|
145
146
|
logger.info('handoff', fields)
|
|
146
147
|
return handoffResponse(decision)
|
|
147
148
|
}
|
|
148
|
-
return denied(logger, fields, decision)
|
|
149
|
+
return denied(logger, fields, decision, isCallback)
|
|
149
150
|
} catch {
|
|
150
151
|
// The last line of the fail-closed guarantee. No `err` is inspected or logged: a caught
|
|
151
152
|
// error can stringify a URL, a header or a body, any of which may carry a credential.
|
|
@@ -182,7 +183,12 @@ function handoffResponse(decision: Extract<Decision, { outcome: 'handoff' }>): R
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
/** Emit the log line for a refusal and render its response. The reason is logged, never returned. */
|
|
185
|
-
function denied(
|
|
186
|
+
function denied(
|
|
187
|
+
logger: ProxyLogger,
|
|
188
|
+
fields: LogFields,
|
|
189
|
+
decision: Extract<Decision, { outcome: 'deny' | 'login' }>,
|
|
190
|
+
isCallback = false,
|
|
191
|
+
): Response {
|
|
186
192
|
const reason: DenyReason = decision.reason
|
|
187
193
|
if (decision.outcome === 'login') {
|
|
188
194
|
logger.info('login', { ...fields, reason, status: decision.status })
|
|
@@ -191,13 +197,66 @@ function denied(logger: ProxyLogger, fields: LogFields, decision: Extract<Decisi
|
|
|
191
197
|
: loginRequired(decision.location, decision.handoff)
|
|
192
198
|
}
|
|
193
199
|
logger.warn('deny', { ...fields, reason, status: decision.status })
|
|
194
|
-
const response =
|
|
200
|
+
const response = isCallback
|
|
201
|
+
? callbackFailure(decision.status)
|
|
202
|
+
: textResponse(decision.status, decision.status === 401 ? 'unauthorized' : decision.status === 503 ? 'unavailable' : 'forbidden')
|
|
195
203
|
if (decision.clearHandoffState !== undefined) {
|
|
196
204
|
response.headers.append('set-cookie', clearHandoffCookie(decision.clearHandoffState))
|
|
197
205
|
}
|
|
198
206
|
return response
|
|
199
207
|
}
|
|
200
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The callback refusal is the one denial a person reads. Every other one answers a program — an XHR,
|
|
211
|
+
* an app request, Caddy itself — but this path is only ever reached by a browser following IAM's
|
|
212
|
+
* redirect, and a viewport containing `forbidden` describes nothing the reader can act on.
|
|
213
|
+
*
|
|
214
|
+
* It deliberately does NOT bounce back into login. A browser that dropped the handoff cookie on this
|
|
215
|
+
* host while keeping IAM's session would be sent straight back here, and the automatic retry becomes
|
|
216
|
+
* an endless loop; a link someone has to click cannot loop on its own.
|
|
217
|
+
*
|
|
218
|
+
* The text is fixed per status and names no reason, because the reasons are not distinguishable from
|
|
219
|
+
* out here anyway: `invalid_session` covers an expired verifier and a code IAM refused alike. Coarse
|
|
220
|
+
* deny reasons stay in the log (`../proxy/CLAUDE.md`).
|
|
221
|
+
*/
|
|
222
|
+
function callbackFailure(status: number): Response {
|
|
223
|
+
const [title, detail] = status === 503
|
|
224
|
+
? ['Sign-in is unavailable', 'The sign-in service could not be reached. Please try again in a moment.']
|
|
225
|
+
: ['Sign-in did not complete', 'The sign-in link has expired or has already been used. Signing in again will issue a new one.']
|
|
226
|
+
// No script and no external reference: this page is served on the application's own origin, and a
|
|
227
|
+
// deployment may sit behind a strict CSP that would blank anything else.
|
|
228
|
+
const body = `<!doctype html>
|
|
229
|
+
<html lang="en">
|
|
230
|
+
<head>
|
|
231
|
+
<meta charset="utf-8">
|
|
232
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
233
|
+
<title>${title}</title>
|
|
234
|
+
<style>
|
|
235
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f4f4f5; color: #18181b;
|
|
236
|
+
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
237
|
+
main { max-width: 27rem; padding: 2rem; }
|
|
238
|
+
h1 { margin: 0 0 .5rem; font-size: 1.25rem; }
|
|
239
|
+
p { margin: 0 0 1.5rem; color: #52525b; }
|
|
240
|
+
a { display: inline-block; padding: .55rem 1.1rem; border-radius: .375rem; background: #18181b; color: #fafafa; text-decoration: none; }
|
|
241
|
+
@media (prefers-color-scheme: dark) {
|
|
242
|
+
body { background: #18181b; color: #fafafa; }
|
|
243
|
+
p { color: #a1a1aa; }
|
|
244
|
+
a { background: #fafafa; color: #18181b; }
|
|
245
|
+
}
|
|
246
|
+
</style>
|
|
247
|
+
</head>
|
|
248
|
+
<body>
|
|
249
|
+
<main>
|
|
250
|
+
<h1>${title}</h1>
|
|
251
|
+
<p>${detail}</p>
|
|
252
|
+
<a href="/">Start again</a>
|
|
253
|
+
</main>
|
|
254
|
+
</body>
|
|
255
|
+
</html>
|
|
256
|
+
`
|
|
257
|
+
return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
|
|
258
|
+
}
|
|
259
|
+
|
|
201
260
|
/** The bounce a document navigation gets: the browser follows it and comes back signed in. */
|
|
202
261
|
function loginRedirect(location: string, handoff: HandoffAttempt): Response {
|
|
203
262
|
return new Response(null, {
|