Skip to content

Commit eec7c7b

Browse files
drankouben-fornefeldfactory-droid[bot]
authored
feat: auth flow via same-origin ory elements (#438)
## Summary Replaces the NextAuth (Auth.js) layer with a first-party, same-origin auth flow built directly on Ory. The dashboard now acts as Hydra's OAuth2 client **and** its consent/logout provider, drives an authorization-code + PKCE flow, and carries the resulting OIDC tokens in a single JWE-encrypted `e2b_session` cookie. Kratos owns the session; the cookie is just a token cache, never the auth gate. This removes `next-auth` / `@auth/core` entirely and the Auth.js request-wrapping that had been fighting our sign-out and tRPC code paths. ## Why - Auth.js wrapped every request and re-issued a JWT session cookie on the way out, which clobbered our sign-out cookie deletion and forced awkward boundary shims (`authjs-boundary`, `authjs-session-boundary`, `authjs-callbacks`). - The OAuth2 redirect/PKCE/state handling was opaque inside the Auth.js provider, making the Hydra ⇄ Kratos consent/logout dance hard to reason about. ## What changed **Auth flow (new routes)** - `GET /api/auth/oauth/start` — builds the Hydra authorization request (PKCE S256, state, nonce), stashes the verifier/state/nonce in a short-lived httpOnly flow cookie, and 307-redirects to `/oauth2/auth`. - `GET /api/auth/oauth/callback/ory` — validates state/nonce/PKCE, exchanges the code, bootstraps the dashboard user from the id_token, seals tokens into `e2b_session`. Failures route to the one-shot `recover` guard so a stale callback can't loop. - `GET /consent` — dashboard is Hydra's consent provider; accepts the consent challenge and folds the identity's profile traits into the id_token (without this, tokens carry `sub`/`iss` but no email/name and bootstrap rejects the login). - `GET /logout` — dashboard is Hydra's logout provider; accepts the logout challenge, routes through Kratos' own logout first so `ory_kratos_session` is cleared before Hydra finalizes. - `sign-out` route now reads the id_token from `e2b_session` to build the Hydra RP-logout URL and clears the cookie on the redirect. **Session / tokens** - New `e2b_session` cookie: JWE (`dir` + `A256GCM`) via `jose`, carrying access/refresh/id tokens. Keyed by new `E2B_SESSION_SECRET` (replaces `AUTH_SECRET`); domain-scoped so it works across the cluster's subdomains. - New `token-refresh` module refreshes the Hydra access token edge-side (direct token-endpoint call, refresh skew, dead/unchanged/refreshed states). - New `kratos-session-edge` helper does an edge-safe Kratos `whoami` for the middleware redirect gate; authoritative enforcement stays in `getAuthContext`. **UI** - Ory Elements auth pages (`/login`, `/registration`, `/recovery`, `/verification`) are now always same-origin — the `NEXT_PUBLIC_ORY_CUSTOM_UI` flag and the legacy redirect fallbacks are gone. - Auth footer links switched from `next/link` to plain `<a>` so the cross-origin start redirect stays a top-level document navigation (a soft nav would turn it into a CORS request Hydra rejects). **Plumbing** - tRPC handler no longer wrapped by `auth()`; auth is resolved per-procedure by the auth middleware (Kratos whoami + `e2b_session`). - Deleted: `src/auth.ts`, the Auth.js boundary/callback shims, `next-auth.d.ts`, and the `[...nextauth]` / `bootstrap-failed` routes. **Dependencies** - Removed `next-auth` (+ `@auth/core` and its transitive `@panva/hkdf`, `preact*`). - Added `jose` and `oauth4webapi` (direct, version-pinned). **Env** - Added `E2B_SESSION_SECRET`, optional `ORY_HYDRA_PUBLIC_URL` (self-hosted Hydra issuer). - Removed `AUTH_SECRET`, `AUTH_TRUST_HOST`, `NEXT_PUBLIC_ORY_CUSTOM_UI`. ## Testing New unit + integration coverage for the rewritten flow: `oauth-client`, `session-cookie`, `token-refresh`, `kratos-session-edge` (unit) and `auth-ory-callback`, `auth-ory-consent`, `auth-ory-logout`, plus the retained entrypoints/account-security suites (integration). The Auth.js session-boundary test was removed with its module. ## Notes for reviewers - **Env / deploy:** set `E2B_SESSION_SECRET` (`openssl rand -hex 32`) in every environment before merge — `AUTH_SECRET` is no longer read. - This is auth-critical; worth a careful pass on the callback failure/recover paths and the logout ordering (Kratos before Hydra finalize). --------- Co-authored-by: ben-fornefeld <ben.fornefeld@gmail.com> Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent eec42cd commit eec7c7b

81 files changed

Lines changed: 3711 additions & 1560 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,23 @@
55
### Dashboard API admin token used for user bootstrap and creator email hydration
66
DASHBOARD_API_ADMIN_TOKEN=your_dashboard_api_admin_token
77

8-
### Auth.js configuration
9-
### Generate with `npx auth secret` or `openssl rand -hex 32`.
10-
AUTH_SECRET=your_auth_secret
8+
### JWE key for the encrypted e2b_session cookie (carries the Hydra OIDC tokens).
9+
### Generate with `openssl rand -hex 32`.
10+
E2B_SESSION_SECRET=your_e2b_session_secret
1111

1212
### Ory Network configuration
1313
ORY_SDK_URL=https://your-project.projects.oryapis.com
14+
### OIDC issuer for the OAuth client. Falls back to ORY_SDK_URL on Ory Network;
15+
### set explicitly for self-hosted Hydra (e.g. http://localhost:4444).
16+
# ORY_HYDRA_PUBLIC_URL=http://localhost:4444
1417
ORY_OAUTH2_CLIENT_ID=your_ory_oauth2_client_id
1518
ORY_OAUTH2_CLIENT_SECRET=your_ory_oauth2_client_secret
1619
### Access-token audience requested from Ory. Must match the backend JWT audience configuration.
1720
ORY_OAUTH2_AUDIENCE=https://api.e2b.dev
1821
### Ory project admin API token used for IdentityApi lookups
1922
ORY_PROJECT_API_TOKEN=your_ory_project_api_token
2023

21-
### Custom Ory UI: "true" on Preview/Staging, unset on Production.
22-
# NEXT_PUBLIC_ORY_CUSTOM_UI=true
23-
### Kratos public URL for the custom UI (self-hosted :4433; falls back to ORY_SDK_URL).
24+
### Browser-facing Kratos public URL for the Elements UI (self-hosted :4433; falls back to ORY_SDK_URL).
2425
# NEXT_PUBLIC_ORY_SDK_URL=http://localhost:4433
2526

2627
### Domain for the E2B cluster
@@ -41,8 +42,13 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev
4142
### Set both when running self-hosted; leave unset to use Ory Network with the PAT above.
4243
# ORY_KRATOS_ADMIN_URL=http://localhost:4434
4344
# ORY_HYDRA_ADMIN_URL=http://localhost:4445
44-
### Set to 1 outside Vercel-hosted production to allow Auth.js to trust the Host header
45-
# AUTH_TRUST_HOST=1
45+
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
4652

4753
# ENABLE_USER_BOOTSTRAP=0
4854

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
runs-on: ubuntu-latest
4646
needs: unit-tests
4747
env:
48-
AUTH_SECRET: test-auth-secret
48+
E2B_SESSION_SECRET: test-session-secret
4949
ORY_SDK_URL: https://test-ory.projects.oryapis.com
5050
ORY_OAUTH2_CLIENT_ID: test-ory-client-id
5151
ORY_OAUTH2_CLIENT_SECRET: test-ory-client-secret

bun.lock

Lines changed: 2 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,15 @@
110110
"fast-xml-parser": "^5.3.5",
111111
"immer": "^10.1.1",
112112
"input-otp": "^1.4.2",
113+
"jose": "^6.2.3",
113114
"micromatch": "^4.0.8",
114115
"motion": "^12.23.25",
115116
"nanoid": "^5.0.9",
116117
"next": "^16.2.7",
117-
"next-auth": "^5.0.0-beta.31",
118118
"next-safe-action": "^8.0.11",
119119
"next-themes": "^0.4.6",
120120
"nuqs": "^2.7.0",
121+
"oauth4webapi": "^3.8.6",
121122
"openapi-fetch": "^0.14.0",
122123
"pathe": "^2.0.3",
123124
"pino": "^9.7.0",
Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import { redirect } from 'next/navigation'
2-
import { buildOryStartURL } from '@/core/server/auth/ory/build-start-url'
32

4-
type PageProps = {
5-
searchParams: Promise<{ returnTo?: string }>
6-
}
7-
8-
export default async function Page({ searchParams }: PageProps) {
9-
const { returnTo } = await searchParams
10-
redirect(buildOryStartURL('signin', returnTo))
3+
export default function Page() {
4+
redirect('/recovery')
115
}

src/app/api/auth/oauth/[...nextauth]/route.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.

src/app/api/auth/oauth/bootstrap-failed/route.ts

Lines changed: 0 additions & 66 deletions
This file was deleted.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import 'server-only'
2+
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { PROTECTED_URLS } from '@/configs/urls'
5+
import { ensureOryUserBootstrapped } from '@/core/server/auth/ory/dashboard-bootstrap'
6+
import { exchangeOryCallback } from '@/core/server/auth/ory/oauth-client'
7+
import {
8+
E2B_OAUTH_FLOW_COOKIE,
9+
ORY_RECOVER_PATH,
10+
openOryFlowState,
11+
} from '@/core/server/auth/ory/oauth-flow'
12+
import { resolveOryRedirectUri } from '@/core/server/auth/ory/oauth-relay'
13+
import {
14+
E2B_SESSION_COOKIE,
15+
ORY_SIGNUP_METADATA_COOKIE,
16+
sealSessionCookie,
17+
sessionCookieOptions,
18+
} from '@/core/server/auth/ory/session-cookie'
19+
import {
20+
buildOryLogoutUrl,
21+
ORY_POST_LOGOUT_PATH,
22+
} from '@/core/server/auth/ory/signout'
23+
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
24+
import { relativeUrlSchema } from '@/core/shared/schemas/url'
25+
26+
// Hydra redirects here with ?code after Kratos created the session. We exchange
27+
// the code (validating state/nonce/PKCE), provision the dashboard user from the
28+
// id_token, then seal the OIDC tokens into e2b_session. Kratos already owns the
29+
// session at this point — this cookie only carries tokens for API access.
30+
export async function GET(request: NextRequest) {
31+
const origin = request.nextUrl.origin
32+
const flow = await openOryFlowState(
33+
request.cookies.get(E2B_OAUTH_FLOW_COOKIE)?.value
34+
)
35+
36+
if (!flow) {
37+
l.warn(
38+
{ key: 'oauth_callback:missing_flow_state' },
39+
'Ory callback hit without a valid flow-state cookie'
40+
)
41+
return finalize(NextResponse.redirect(new URL(ORY_RECOVER_PATH, origin)))
42+
}
43+
44+
let tokens: Awaited<ReturnType<typeof exchangeOryCallback>>
45+
try {
46+
tokens = await exchangeOryCallback({
47+
// A genuine global URL — oauth4webapi rejects NextURL (not `instanceof URL`).
48+
currentUrl: new URL(request.url),
49+
expectedState: flow.state,
50+
expectedNonce: flow.nonce,
51+
codeVerifier: flow.codeVerifier,
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,
55+
})
56+
} catch (error) {
57+
l.error(
58+
{
59+
key: 'oauth_callback:exchange_failed',
60+
error: serializeErrorForLog(error),
61+
},
62+
'Ory authorization code exchange failed'
63+
)
64+
return finalize(NextResponse.redirect(new URL(ORY_RECOVER_PATH, origin)))
65+
}
66+
67+
const bootstrapped = await ensureOryUserBootstrapped({
68+
accessToken: tokens.accessToken,
69+
idToken: tokens.idToken,
70+
provider: 'ory',
71+
})
72+
73+
if (!bootstrapped) {
74+
l.error(
75+
{ key: 'oauth_callback:bootstrap_failed' },
76+
'dashboard bootstrap failed; ending the Ory session without a dashboard cookie'
77+
)
78+
// Don't strand the user with a half-provisioned login: end the Ory + Kratos
79+
// session via RP-logout (falling back to home if no id_token is available).
80+
const logoutUrl = tokens.idToken
81+
? await buildOryLogoutUrl({ idToken: tokens.idToken, origin })
82+
: null
83+
return finalize(
84+
NextResponse.redirect(logoutUrl ?? new URL(ORY_POST_LOGOUT_PATH, origin))
85+
)
86+
}
87+
88+
const sealed = await sealSessionCookie({
89+
accessToken: tokens.accessToken,
90+
refreshToken: tokens.refreshToken,
91+
idToken: tokens.idToken,
92+
expiresAt: tokens.expiresAt,
93+
})
94+
95+
// Re-validate here too: the flow cookie is read back as a raw string, and
96+
// `new URL()` would otherwise escape the origin on a crafted returnTo.
97+
const parsedReturnTo = relativeUrlSchema.safeParse(flow.returnTo)
98+
const destination = parsedReturnTo.success
99+
? parsedReturnTo.data
100+
: PROTECTED_URLS.DASHBOARD
101+
const response = finalize(NextResponse.redirect(new URL(destination, origin)))
102+
response.cookies.set(
103+
E2B_SESSION_COOKIE,
104+
sealed,
105+
sessionCookieOptions(request.nextUrl.host)
106+
)
107+
return response
108+
}
109+
110+
// Clears the one-shot transient cookies on every exit path.
111+
function finalize(response: NextResponse): NextResponse {
112+
response.cookies.delete(E2B_OAUTH_FLOW_COOKIE)
113+
response.cookies.delete(ORY_SIGNUP_METADATA_COOKIE)
114+
return response
115+
}
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+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ export async function GET(request: NextRequest) {
1212

1313
l.error(
1414
{
15-
key: 'oauth_recover:auth_js_error',
15+
key: 'oauth_recover:flow_failed',
1616
context: { error_code: errorCode, already_attempted: alreadyAttempted },
1717
},
18-
'Auth.js OAuth flow failed; recovering user'
18+
'OAuth flow failed; recovering user once before bailing to home'
1919
)
2020

2121
const destination = alreadyAttempted ? '/' : AUTH_URLS.SIGN_IN

0 commit comments

Comments
 (0)