-
Notifications
You must be signed in to change notification settings - Fork 20
fix(server): remove LOBU_NO_AUTH, add /api/exchange-token PAT handoff #827
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,10 +7,13 @@ | |
| * - POST /api/:orgSlug/tokens - Create org-scoped personal access tokens | ||
| */ | ||
|
|
||
| import { createHmac } from 'node:crypto'; | ||
| import { type Context, Hono } from 'hono'; | ||
| import { createDbClientFromEnv } from '../db/client'; | ||
| import type { Env } from '../index'; | ||
| import { errorMessage } from '../utils/errors'; | ||
| import { resolveBaseUrl } from './base-url'; | ||
| import { createAuth } from './index'; | ||
| import { mcpAuth, requireAuth } from './middleware'; | ||
| import { OAuthClientsStore } from './oauth/clients'; | ||
| import { PersonalAccessTokenService } from './tokens'; | ||
|
|
@@ -198,4 +201,73 @@ credentialRoutes.post('/:orgSlug/tokens', mcpAuth, async (c) => { | |
| } | ||
| }); | ||
|
|
||
| /** | ||
| * Exchange a Personal Access Token for a Better Auth session cookie. | ||
| * | ||
| * Lets a holder of a valid PAT (CLI users, the macOS menu-bar app, deep links | ||
| * from the operator's terminal) hop into the web UI without typing a password. | ||
| * The endpoint validates the PAT, mints a fresh session row tied to the same | ||
| * user, signs the session token with BETTER_AUTH_SECRET (matching what | ||
| * Better Auth would set), and 302-redirects to `next` (default `/`). | ||
| * | ||
| * `next` is restricted to relative paths to prevent open-redirect abuse. The | ||
| * Referrer-Policy header keeps the PAT out of the next page's Referer. | ||
| */ | ||
| credentialRoutes.get('/exchange-token', async (c) => { | ||
| // Don't leak the PAT into the next request's Referer header. | ||
| c.header('Referrer-Policy', 'no-referrer'); | ||
|
|
||
| const token = c.req.query('token')?.trim(); | ||
| if (!token) { | ||
| return c.json({ error: 'missing_token', error_description: 'token query param is required' }, 400); | ||
| } | ||
|
|
||
| const sql = createDbClientFromEnv(c.env); | ||
| const patService = new PersonalAccessTokenService(sql); | ||
| const authInfo = await patService.verify(token); | ||
| if (!authInfo) { | ||
| return c.json({ error: 'invalid_token', error_description: 'token is invalid, expired, or revoked' }, 401); | ||
| } | ||
|
|
||
| const secret = c.env.BETTER_AUTH_SECRET; | ||
| if (!secret) { | ||
| return c.json( | ||
| { error: 'server_misconfigured', error_description: 'BETTER_AUTH_SECRET not set' }, | ||
| 500 | ||
| ); | ||
| } | ||
|
|
||
| const auth = await createAuth(c.env, c.req.raw); | ||
| const ctx = await auth.$context; | ||
| const session = await ctx.internalAdapter.createSession(authInfo.userId); | ||
| if (!session?.token) { | ||
| return c.json({ error: 'session_create_failed', error_description: 'failed to mint session' }, 500); | ||
| } | ||
|
|
||
| // Match Better Auth's cookie shape: `<token>.<base64(HMAC-SHA256(token, secret))>`, | ||
| // URL-encoded. Cookie name picks up the __Secure- prefix when the request | ||
| // arrived over HTTPS so it stays compatible with the prod baseURL rule. | ||
| const sig = createHmac('sha256', secret).update(session.token).digest('base64'); | ||
| const cookieValue = encodeURIComponent(`${session.token}.${sig}`); | ||
| // Match Better Auth's cookie-prefix rule: __Secure- iff the public baseURL | ||
| // is https. Resolve via the same helper used during sign-in so the prefix | ||
| // matches even when TLS is terminated by a reverse proxy (Tailscale Funnel, | ||
| // nginx, cloudflared) and the loopback bind itself speaks plain HTTP. | ||
| const isHttps = resolveBaseUrl({ request: c.req.raw }).startsWith('https://'); | ||
| const cookieName = isHttps ? '__Secure-better-auth.session_token' : 'better-auth.session_token'; | ||
| const cookieParts = [ | ||
| `${cookieName}=${cookieValue}`, | ||
| 'Path=/', | ||
| 'HttpOnly', | ||
| 'SameSite=Lax', | ||
| `Max-Age=${60 * 60 * 24 * 7}`, | ||
| ]; | ||
| if (isHttps) cookieParts.push('Secure'); | ||
| c.header('Set-Cookie', cookieParts.join('; ')); | ||
|
|
||
|
Comment on lines
+260
to
+267
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In hosted deployments with Useful? React with 👍 / 👎. |
||
| const rawNext = c.req.query('next') ?? '/'; | ||
| const safeNext = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/'; | ||
| return c.redirect(safeNext, 302); | ||
| }); | ||
|
|
||
| export { credentialRoutes }; | ||
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user creates a scoped PAT (for example
mcp:readfor CI or an external MCP client), this endpoint now accepts that token and mints an unrestricted Better Auth web session for the PAT's user. That bypasses the existingauthSource === 'pat'guard on privileged web-only actions such as creating new server tokens, because the follow-up request is authenticated as a normal session rather than as the original scoped PAT. The exchange should be limited to the bootstrap/local handoff token or otherwise enforce high-trust scopes/purpose before creating a browser session.Useful? React with 👍 / 👎.