@fabrika/proxy-core 0.0.4 → 0.0.6

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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. 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.4",
3
+ "version": "0.0.6",
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.4",
30
- "@fabrika/proxy-contract": "^0.0.4",
29
+ "@fabrika/auth-core": "^0.0.6",
30
+ "@fabrika/proxy-contract": "^0.0.6",
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 decision = forwarded.url.pathname === AUTH_CALLBACK_PATH
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(logger: ProxyLogger, fields: LogFields, decision: Extract<Decision, { outcome: 'deny' | 'login' }>): Response {
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 = textResponse(decision.status, decision.status === 401 ? 'unauthorized' : decision.status === 503 ? 'unavailable' : 'forbidden')
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, {