Skip to content

Commit 4d5f63d

Browse files
committed
add login/logout relay for previews
1 parent d02c5c9 commit 4d5f63d

10 files changed

Lines changed: 222 additions & 12 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev
4343
# ORY_KRATOS_ADMIN_URL=http://localhost:4434
4444
# ORY_HYDRA_ADMIN_URL=http://localhost:4445
4545

46+
### Fixed host whose OAuth callback/logout relays are registered in Hydra. Set on
47+
### preview deployments only (dynamic preview hosts can't register their own
48+
### redirect URIs); leave unset on staging/production/local, where sign-in stays
49+
### host-direct. The relay paths (/api/auth/oauth/relay, /api/auth/oauth/logout-relay)
50+
### must be added to the OAuth2 client's redirect_uris / post_logout_redirect_uris.
51+
# ORY_OAUTH_RELAY_ORIGIN=https://e2b-staging.dev
52+
4653
# ENABLE_USER_BOOTSTRAP=0
4754

4855
### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1)

src/app/api/auth/oauth/callback/ory/route.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { ensureOryUserBootstrapped } from '@/core/server/auth/ory/dashboard-boot
66
import { exchangeOryCallback } from '@/core/server/auth/ory/oauth-client'
77
import {
88
E2B_OAUTH_FLOW_COOKIE,
9-
OAUTH_CALLBACK_PATH,
109
ORY_RECOVER_PATH,
1110
openOryFlowState,
1211
} from '@/core/server/auth/ory/oauth-flow'
12+
import { resolveOryRedirectUri } from '@/core/server/auth/ory/oauth-relay'
1313
import {
1414
E2B_SESSION_COOKIE,
1515
ORY_SIGNUP_METADATA_COOKIE,
@@ -49,7 +49,9 @@ export async function GET(request: NextRequest) {
4949
expectedState: flow.state,
5050
expectedNonce: flow.nonce,
5151
codeVerifier: flow.codeVerifier,
52-
redirectUri: new URL(OAUTH_CALLBACK_PATH, origin).toString(),
52+
// Must be byte-identical to the authorize-time value (the registered
53+
// relay URI on previews), not the host the code was delivered to.
54+
redirectUri: resolveOryRedirectUri(origin).redirectUri,
5355
})
5456
} catch (error) {
5557
l.error(
@@ -76,7 +78,7 @@ export async function GET(request: NextRequest) {
7678
// Don't strand the user with a half-provisioned login: end the Ory + Kratos
7779
// session via RP-logout (falling back to home if no id_token is available).
7880
const logoutUrl = tokens.idToken
79-
? buildOryLogoutUrl({ idToken: tokens.idToken, origin })
81+
? await buildOryLogoutUrl({ idToken: tokens.idToken, origin })
8082
: null
8183
return finalize(
8284
NextResponse.redirect(logoutUrl ?? new URL(ORY_POST_LOGOUT_PATH, origin))
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import 'server-only'
2+
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import {
5+
isAllowedRelayTarget,
6+
openRelayState,
7+
} from '@/core/server/auth/ory/oauth-relay'
8+
import { ORY_POST_LOGOUT_PATH } from '@/core/server/auth/ory/signout'
9+
import { l } from '@/core/shared/clients/logger/logger'
10+
11+
// Fixed-host post-logout relay (mirror of the login relay). Hydra returns here
12+
// after ending the session, with the sealed `state` carrying the preview origin;
13+
// we bounce the browser back to that preview's home. The sign-out route already
14+
// cleared the cookies on the preview before the Hydra hop, so this is a pure
15+
// redirect. See oauth-relay.ts.
16+
export async function GET(request: NextRequest) {
17+
const origin = request.nextUrl.origin
18+
const target = await openRelayState(request.nextUrl.searchParams.get('state'))
19+
20+
if (!target || !isAllowedRelayTarget(target)) {
21+
l.warn(
22+
{ key: 'oauth_logout_relay:invalid_target' },
23+
'Ory logout relay hit without a valid sealed target'
24+
)
25+
return NextResponse.redirect(new URL(ORY_POST_LOGOUT_PATH, origin))
26+
}
27+
28+
return NextResponse.redirect(new URL(ORY_POST_LOGOUT_PATH, target))
29+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import 'server-only'
2+
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import {
5+
OAUTH_CALLBACK_PATH,
6+
ORY_RECOVER_PATH,
7+
} from '@/core/server/auth/ory/oauth-flow'
8+
import {
9+
isAllowedRelayTarget,
10+
openRelayState,
11+
} from '@/core/server/auth/ory/oauth-relay'
12+
import { l } from '@/core/shared/clients/logger/logger'
13+
14+
// Fixed-host relay for preview deployments. Hydra is configured with this host's
15+
// /api/auth/oauth/relay as the single registered redirect_uri; previews encode
16+
// their own origin in the sealed `state`. We bounce the browser — carrying
17+
// code/state/iss (and any error) verbatim — to the originating preview's real
18+
// callback, which finishes the PKCE exchange (its verifier never left that
19+
// origin). See oauth-relay.ts. Never touches cookies.
20+
export async function GET(request: NextRequest) {
21+
const origin = request.nextUrl.origin
22+
const state = request.nextUrl.searchParams.get('state')
23+
const target = await openRelayState(state)
24+
25+
if (!target || !isAllowedRelayTarget(target)) {
26+
l.warn(
27+
{
28+
key: 'oauth_relay:invalid_target',
29+
context: { hasState: Boolean(state) },
30+
},
31+
'Ory relay hit without a valid sealed target'
32+
)
33+
return NextResponse.redirect(new URL(ORY_RECOVER_PATH, origin))
34+
}
35+
36+
const destination = new URL(OAUTH_CALLBACK_PATH, target)
37+
destination.search = request.nextUrl.search
38+
return NextResponse.redirect(destination)
39+
}

src/app/api/auth/oauth/start/route.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import {
77
import { buildOryAuthorizationRequest } from '@/core/server/auth/ory/oauth-client'
88
import {
99
E2B_OAUTH_FLOW_COOKIE,
10-
OAUTH_CALLBACK_PATH,
1110
ORY_RECOVER_PATH,
1211
oryFlowCookieOptions,
1312
sealOryFlowState,
1413
} from '@/core/server/auth/ory/oauth-flow'
14+
import {
15+
resolveOryRedirectUri,
16+
sealRelayState,
17+
} from '@/core/server/auth/ory/oauth-relay'
1518
import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/session-cookie'
1619
import {
1720
encodeOrySignupMetadata,
@@ -34,11 +37,17 @@ export async function GET(request: NextRequest) {
3437
const returnTo = normalizeOryReturnTo(
3538
request.nextUrl.searchParams.get('returnTo')
3639
)
37-
const redirectUri = new URL(OAUTH_CALLBACK_PATH, origin).toString()
40+
const { redirectUri, relayTarget } = resolveOryRedirectUri(origin)
3841

3942
let authorization: Awaited<ReturnType<typeof buildOryAuthorizationRequest>>
4043
try {
41-
authorization = await buildOryAuthorizationRequest(intent, redirectUri)
44+
// Relay mode carries the preview origin in a sealed state; direct mode keeps
45+
// the original two-arg call so staging/production behavior is unchanged.
46+
authorization = relayTarget
47+
? await buildOryAuthorizationRequest(intent, redirectUri, {
48+
state: await sealRelayState(relayTarget),
49+
})
50+
: await buildOryAuthorizationRequest(intent, redirectUri)
4251
} catch (error) {
4352
l.error(
4453
{

src/core/server/auth/ory/oauth-client.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ function oryClient(env: OryOAuthEnv): oauth.Client {
9393

9494
export async function buildOryAuthorizationRequest(
9595
intent: OryAuthIntent,
96-
redirectUri: string
96+
redirectUri: string,
97+
options?: { state?: string }
9798
): Promise<OryAuthorizationRequest> {
9899
const env = readOryOAuthEnv()
99100
const as = await discoverAuthorizationServer(env)
@@ -104,7 +105,9 @@ export async function buildOryAuthorizationRequest(
104105

105106
const codeVerifier = oauth.generateRandomCodeVerifier()
106107
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier)
107-
const state = oauth.generateRandomState()
108+
// In relay mode the caller supplies a sealed state carrying the preview
109+
// origin; it doubles as the CSRF state validated at the callback.
110+
const state = options?.state ?? oauth.generateRandomState()
108111
const nonce = oauth.generateRandomNonce()
109112

110113
const url = new URL(as.authorization_endpoint)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// OAuth callback relay for preview deployments. Ory does not allow wildcard
2+
// redirect URIs, so previews — whose host is dynamic per branch — cannot
3+
// register their own callback. Instead we register ONE stable callback on a
4+
// fixed host (ORY_OAUTH_RELAY_ORIGIN) and point Hydra there for every preview,
5+
// encoding the originating preview origin in the sealed OAuth `state`. The fixed
6+
// host bounces the browser (carrying code/state/iss) back to the preview's real
7+
// callback, which finishes the PKCE exchange using the same registered
8+
// redirect_uri string — the token request only requires the redirect_uri to
9+
// match the authorize-time value, not to be where the code was delivered.
10+
//
11+
// The PKCE verifier lives in a host-only cookie on the preview and never reaches
12+
// the relay. `state` is sealed with the shared cookie crypto (E2B_SESSION_SECRET,
13+
// identical across the fixed host and previews), so the target is tamper-proof.
14+
//
15+
// No next/headers import here so this stays importable from edge middleware
16+
// (signout.ts pulls it in for the post-logout path).
17+
18+
import { EncryptJWT, jwtDecrypt } from 'jose'
19+
import { isLoopbackUrl } from '@/core/shared/schemas/url'
20+
import { CONTENT_ENCRYPTION, deriveKey, KEY_ALGORITHM } from './cookie-crypto'
21+
import { OAUTH_CALLBACK_PATH } from './oauth-flow'
22+
23+
export const OAUTH_RELAY_PATH = '/api/auth/oauth/relay'
24+
export const OAUTH_LOGOUT_RELAY_PATH = '/api/auth/oauth/logout-relay'
25+
26+
// The fixed host whose relay endpoints are registered in Hydra. Set on preview
27+
// deployments only; unset on staging/production/local, where the flow stays
28+
// host-direct and behaves exactly as before.
29+
export function readRelayOrigin(): string | undefined {
30+
const value = process.env.ORY_OAUTH_RELAY_ORIGIN
31+
if (!value) return undefined
32+
return value.replace(/\/$/, '')
33+
}
34+
35+
// Relay mode applies only when a fixed origin is configured AND differs from the
36+
// request origin. On the fixed host itself (and everywhere relay is unset) the
37+
// request resolves to its own callback, i.e. today's behavior.
38+
export function resolveOryRedirectUri(requestOrigin: string): {
39+
redirectUri: string
40+
relayTarget?: string
41+
} {
42+
const relay = readRelayOrigin()
43+
if (relay && relay !== requestOrigin) {
44+
return {
45+
redirectUri: new URL(OAUTH_RELAY_PATH, relay).toString(),
46+
relayTarget: requestOrigin,
47+
}
48+
}
49+
50+
return { redirectUri: new URL(OAUTH_CALLBACK_PATH, requestOrigin).toString() }
51+
}
52+
53+
// Carries the originating preview origin through Hydra in the OAuth `state`
54+
// (login) or RP-logout `state`. The random `r` gives the login state CSRF
55+
// entropy beyond the per-seal random IV.
56+
export async function sealRelayState(target: string): Promise<string> {
57+
return new EncryptJWT({ t: target, r: crypto.randomUUID() })
58+
.setProtectedHeader({ alg: KEY_ALGORITHM, enc: CONTENT_ENCRYPTION })
59+
.setIssuedAt()
60+
.encrypt(await deriveKey())
61+
}
62+
63+
export async function openRelayState(
64+
value: string | null | undefined
65+
): Promise<string | null> {
66+
if (!value) return null
67+
68+
try {
69+
const { payload } = await jwtDecrypt(value, await deriveKey())
70+
return typeof payload.t === 'string' ? payload.t : null
71+
} catch {
72+
return null
73+
}
74+
}
75+
76+
// Open-redirect guard: a relay target must be a first-party origin. Production
77+
// requires HTTPS under NEXT_PUBLIC_E2B_DOMAIN (e.g. `*.e2b-staging.dev`); local
78+
// dev also accepts loopback so the relay path can be exercised across ports.
79+
export function isAllowedRelayTarget(target: string): boolean {
80+
let url: URL
81+
try {
82+
url = new URL(target)
83+
} catch {
84+
return false
85+
}
86+
87+
if (url.protocol === 'http:' && isLoopbackUrl(target)) {
88+
return process.env.NODE_ENV !== 'production'
89+
}
90+
91+
if (url.protocol !== 'https:') return false
92+
93+
const base = process.env.NEXT_PUBLIC_E2B_DOMAIN
94+
if (!base) return false
95+
96+
return url.hostname === base || url.hostname.endsWith(`.${base}`)
97+
}

src/core/server/auth/ory/signout-flow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ export async function completeOrySignOut(origin = BASE_URL): Promise<string> {
3232

3333
if (!idToken) return fallback
3434

35-
return buildOryLogoutUrl({ idToken, origin })?.toString() ?? fallback
35+
const logoutUrl = await buildOryLogoutUrl({ idToken, origin })
36+
return logoutUrl?.toString() ?? fallback
3637
}
Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,37 @@
1+
import {
2+
OAUTH_LOGOUT_RELAY_PATH,
3+
readRelayOrigin,
4+
sealRelayState,
5+
} from './oauth-relay'
6+
17
export const ORY_POST_LOGOUT_PATH = '/'
28

39
// Builds Hydra's RP-initiated logout URL. With the id_token as the hint Hydra
410
// ends both its own OAuth2 session and (since it delegates login to Kratos) the
511
// Kratos session, then returns the browser to post_logout_redirect_uri.
6-
export function buildOryLogoutUrl({
12+
export async function buildOryLogoutUrl({
713
idToken,
814
origin,
915
}: {
1016
idToken: string
1117
origin: string
12-
}): URL | null {
18+
}): Promise<URL | null> {
1319
const issuer = process.env.ORY_HYDRA_PUBLIC_URL ?? process.env.ORY_SDK_URL
1420
if (!issuer) return null
1521

16-
const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin)
22+
// Previews can't register their dynamic host as a post_logout_redirect_uri,
23+
// so route through the fixed relay host and carry the real origin in `state`
24+
// (Hydra returns it to the post-logout URI). See oauth-relay.ts.
25+
const relay = readRelayOrigin()
26+
let postLogoutUrl: URL
27+
let relayState: string | undefined
28+
if (relay && relay !== origin) {
29+
postLogoutUrl = new URL(OAUTH_LOGOUT_RELAY_PATH, relay)
30+
relayState = await sealRelayState(origin)
31+
} else {
32+
postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin)
33+
}
34+
1735
const logoutUrl = new URL(
1836
`${issuer.replace(/\/$/, '')}/oauth2/sessions/logout`
1937
)
@@ -22,6 +40,7 @@ export function buildOryLogoutUrl({
2240
'post_logout_redirect_uri',
2341
postLogoutUrl.toString()
2442
)
43+
if (relayState) logoutUrl.searchParams.set('state', relayState)
2544

2645
return logoutUrl
2746
}

src/lib/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export const serverSchema = z.object({
2424
ORY_PROJECT_API_TOKEN: z.string().min(1).optional(),
2525
ORY_KRATOS_ADMIN_URL: z.url().optional(),
2626
ORY_HYDRA_ADMIN_URL: z.url().optional(),
27+
// Fixed host whose OAuth callback/logout relays are registered in Hydra. Set
28+
// on preview deployments (dynamic hosts can't register their own redirect
29+
// URIs); unset on staging/production/local, where the flow stays host-direct.
30+
ORY_OAUTH_RELAY_ORIGIN: z.url().optional(),
2731

2832
OTEL_SERVICE_NAME: z.string().optional(),
2933
OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(),

0 commit comments

Comments
 (0)