Skip to content

Commit 2b5e733

Browse files
committed
feat: add ory auth provider integration
1 parent c539a4a commit 2b5e733

71 files changed

Lines changed: 4057 additions & 157 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: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,28 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
2424
### Auth provider: supabase (default) or ory
2525
# AUTH_PROVIDER=supabase
2626

27-
### Ory Network SDK URL (required when AUTH_PROVIDER=ory)
27+
### Ory Network configuration (required when AUTH_PROVIDER=ory)
28+
### SDK URL of the Ory Network project (or custom domain like https://auth.e2b.dev)
2829
# ORY_SDK_URL=https://your-project.projects.oryapis.com
30+
### OAuth2 client credentials issued by Ory for this dashboard deployment
31+
# ORY_OAUTH2_CLIENT_ID=
32+
# ORY_OAUTH2_CLIENT_SECRET=
33+
### Access-token audience requested from Ory. Must match infra AUTH_PROVIDER_CONFIG.jwt[].issuer.audiences.
34+
# ORY_OAUTH2_AUDIENCE=https://api.e2b.dev
35+
### Ory project admin API token used by oryAuthAdmin (IdentityApi lookups)
36+
# ORY_PROJECT_API_TOKEN=
37+
### Dashboard API admin token used to bootstrap newly signed-in Ory users
38+
# DASHBOARD_API_ADMIN_TOKEN=
39+
40+
### Auth.js configuration (required when AUTH_PROVIDER=ory)
41+
### Generate with `npx auth secret` or `openssl rand -hex 32`. Used to encrypt the JWT session cookie.
42+
# AUTH_SECRET=
43+
### Set to 1 outside Vercel-hosted production to allow Auth.js to trust the Host header
44+
# AUTH_TRUST_HOST=1
45+
46+
### Legacy Supabase bootstrap fallback used by dashboard route team resolution.
47+
### Ory sign-in bootstrap does not depend on this flag.
48+
# ENABLE_USER_BOOTSTRAP=0
2949

3050
### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1)
3151
# BILLING_API_URL=https://billing.e2b.dev

bun.lock

Lines changed: 18 additions & 0 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"@opentelemetry/sdk-metrics": "^2.0.1",
6161
"@opentelemetry/sdk-node": "^0.203.0",
6262
"@opentelemetry/semantic-conventions": "^1.36.0",
63+
"@ory/client-fetch": "^1.22.37",
6364
"@radix-ui/react-avatar": "^1.1.4",
6465
"@radix-ui/react-checkbox": "^1.3.3",
6566
"@radix-ui/react-dialog": "^1.1.15",
@@ -111,6 +112,7 @@
111112
"motion": "^12.23.25",
112113
"nanoid": "^5.0.9",
113114
"next": "^16.2.7",
115+
"next-auth": "^5.0.0-beta.31",
114116
"next-safe-action": "^8.0.11",
115117
"next-themes": "^0.4.6",
116118
"nuqs": "^2.7.0",

scripts/check-app-env.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,25 @@ const schema = serverSchema
5858
path: ['PLAIN_API_KEY'],
5959
}
6060
)
61+
.refine(
62+
(data) => {
63+
if (data.AUTH_PROVIDER !== 'ory') return true
64+
65+
return Boolean(
66+
data.AUTH_SECRET &&
67+
data.ORY_SDK_URL &&
68+
data.ORY_OAUTH2_CLIENT_ID &&
69+
data.ORY_OAUTH2_CLIENT_SECRET &&
70+
data.ORY_OAUTH2_AUDIENCE &&
71+
data.ORY_PROJECT_API_TOKEN &&
72+
data.DASHBOARD_API_ADMIN_TOKEN
73+
)
74+
},
75+
{
76+
message:
77+
'AUTH_PROVIDER=ory requires AUTH_SECRET, ORY_SDK_URL, ORY_OAUTH2_CLIENT_ID, ORY_OAUTH2_CLIENT_SECRET, ORY_OAUTH2_AUDIENCE, ORY_PROJECT_API_TOKEN, and DASHBOARD_API_ADMIN_TOKEN',
78+
path: ['AUTH_PROVIDER'],
79+
}
80+
)
6181

6282
validateEnv(schema)

src/app/(auth)/sign-in/login-form.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ export default function Login() {
8585
window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}`
8686
}
8787

88+
if (AUTH_MIGRATION_IN_PROGRESS) {
89+
return (
90+
<div className="flex w-full flex-col gap-3">
91+
<h1>Sign in</h1>
92+
<p className="text-fg-secondary leading-6">
93+
Sign-ups and sign-ins are temporarily paused while we migrate our
94+
authentication system. Please try again later.
95+
</p>
96+
</div>
97+
)
98+
}
99+
88100
return (
89101
<div className="flex w-full flex-col">
90102
<h1>Sign in</h1>
@@ -153,12 +165,6 @@ export default function Login() {
153165
<Button
154166
type="submit"
155167
loading={isExecuting ? 'Signing in...' : undefined}
156-
disabled={AUTH_MIGRATION_IN_PROGRESS}
157-
title={
158-
AUTH_MIGRATION_IN_PROGRESS
159-
? 'Sign-ins are temporarily paused'
160-
: undefined
161-
}
162168
>
163169
Sign in
164170
</Button>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
import { AUTH_URLS } from '@/configs/urls'
4+
import { l } from '@/core/shared/clients/logger/logger'
5+
6+
// Auth.js renders its built-in `${basePath}/error` page when something fails
7+
// during the OAuth dance (most commonly a stale state/PKCE/nonce cookie that
8+
// expired while the user lingered on the Ory hosted UI). We point
9+
// `pages.error` here so the user never sees that page - we log the failure
10+
// for observability and bounce them back to /sign-in, which restarts the
11+
// flow with fresh cookies via the middleware -> oauth-start chain.
12+
//
13+
// A short-lived cookie prevents tight loops when the underlying failure is
14+
// genuinely persistent (e.g. ORY_SDK_URL misconfigured). After one recovery
15+
// attempt in the window, subsequent failures fall back to the marketing
16+
// root so the user isn't bounced indefinitely.
17+
const RECOVERY_COOKIE = 'auth_recover_attempted'
18+
const RECOVERY_COOKIE_MAX_AGE_SECONDS = 30
19+
20+
export async function GET(request: NextRequest) {
21+
const errorCode = request.nextUrl.searchParams.get('error') ?? 'unknown'
22+
const alreadyAttempted = request.cookies.get(RECOVERY_COOKIE)?.value === '1'
23+
24+
l.error(
25+
{
26+
key: 'oauth_recover:auth_js_error',
27+
context: { error_code: errorCode, already_attempted: alreadyAttempted },
28+
},
29+
'Auth.js OAuth flow failed; recovering user'
30+
)
31+
32+
const destination = alreadyAttempted ? '/' : AUTH_URLS.SIGN_IN
33+
const response = NextResponse.redirect(new URL(destination, request.url))
34+
35+
if (alreadyAttempted) {
36+
response.cookies.delete(RECOVERY_COOKIE)
37+
} else {
38+
response.cookies.set(RECOVERY_COOKIE, '1', {
39+
maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS,
40+
httpOnly: true,
41+
sameSite: 'lax',
42+
path: '/',
43+
secure: process.env.NODE_ENV === 'production',
44+
})
45+
}
46+
47+
return response
48+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { signIn } from '@/auth'
2+
import { normalizeOryReturnTo } from '@/core/server/auth/ory/build-start-url'
3+
import {
4+
readOrySignupMetadataFromHeaders,
5+
setOrySignupMetadataCookie,
6+
} from '@/core/server/auth/ory/signup-metadata'
7+
8+
// Server-side entry point for the Ory OAuth2 flow. Pages redirect here
9+
// instead of rendering a client-side form so that Auth.js can set its
10+
// state/PKCE cookies (only allowed in route handlers / server actions
11+
// / middleware) without any client JS in the loop.
12+
//
13+
// `intent=signup` forwards `prompt=registration` to Hydra, which routes
14+
// to its registration UI (`urls.registration`, default `/ui/registration`)
15+
// instead of the login UI.
16+
//
17+
// `intent=reauth` forwards `prompt=login`, forcing Hydra to redo the login
18+
// flow even with an active session so we get a fresh `auth_time`. Used to
19+
// re-authenticate before sensitive account changes (password).
20+
// https://www.ory.com/docs/oauth2-oidc/authorization-code-flow
21+
export async function GET(request: Request) {
22+
const url = new URL(request.url)
23+
const intent = url.searchParams.get('intent')
24+
const redirectTo =
25+
normalizeOryReturnTo(url.searchParams.get('returnTo')) ?? '/dashboard'
26+
27+
const authorizationParams =
28+
intent === 'signup'
29+
? { prompt: 'registration' }
30+
: intent === 'reauth'
31+
? { prompt: 'login' }
32+
: undefined
33+
34+
if (intent === 'signup') {
35+
await setOrySignupMetadataCookie(
36+
readOrySignupMetadataFromHeaders(request.headers)
37+
)
38+
}
39+
40+
await signIn('ory', { redirectTo }, authorizationParams)
41+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { handlers } from '@/auth'
2+
3+
export const { GET, POST } = handlers
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import 'server-only'
2+
3+
import type { NextRequest } from 'next/server'
4+
import { NextResponse } from 'next/server'
5+
import { signOut } from '@/auth'
6+
import {
7+
buildOryLogoutUrl,
8+
ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE,
9+
ORY_POST_LOGOUT_PATH,
10+
} from '@/core/server/auth/ory/signout'
11+
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
12+
13+
export async function GET(request: NextRequest) {
14+
const origin = request.nextUrl.origin
15+
const idToken = request.cookies.get(
16+
ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE
17+
)?.value
18+
19+
try {
20+
await signOut({ redirect: false })
21+
} catch (error) {
22+
l.warn(
23+
{
24+
key: 'oauth_bootstrap_failed:authjs_sign_out:error',
25+
error: serializeErrorForLog(error),
26+
},
27+
'Auth.js signOut() failed after Ory bootstrap failure'
28+
)
29+
}
30+
31+
const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null
32+
33+
if (!logoutUrl) {
34+
l.error(
35+
{
36+
key: 'oauth_bootstrap_failed:missing_logout_context',
37+
context: {
38+
has_id_token: !!idToken,
39+
has_ory_sdk_url: !!process.env.ORY_SDK_URL,
40+
},
41+
},
42+
'Could not perform Ory logout after bootstrap failure'
43+
)
44+
}
45+
46+
const response = NextResponse.redirect(
47+
logoutUrl ?? new URL(ORY_POST_LOGOUT_PATH, origin)
48+
)
49+
response.cookies.delete(ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE)
50+
return response
51+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'server-only'
2+
3+
import type { NextRequest } from 'next/server'
4+
import { NextResponse } from 'next/server'
5+
import { auth, signOut } from '@/auth'
6+
import { revokeKratosSessionsForIdentity } from '@/core/server/auth/ory/kratos-session'
7+
import {
8+
buildOryLogoutUrl,
9+
ORY_POST_LOGOUT_PATH,
10+
} from '@/core/server/auth/ory/signout'
11+
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
12+
13+
export async function GET(request: NextRequest) {
14+
const origin = request.nextUrl.origin
15+
const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin)
16+
17+
let idToken: string | undefined
18+
let identityId: string | undefined
19+
try {
20+
const session = await auth()
21+
idToken = session?.idToken
22+
// The Kratos identity id resolved at sign-in — NOT the OIDC subject (which
23+
// is the E2B user id) — so we revoke the right identity's Kratos sessions.
24+
identityId = session?.identityId
25+
} catch (error) {
26+
l.warn(
27+
{
28+
key: 'oauth_signout:read_session:error',
29+
error: serializeErrorForLog(error),
30+
},
31+
'failed to read Auth.js session before sign-out'
32+
)
33+
}
34+
35+
try {
36+
await signOut({ redirect: false })
37+
} catch (error) {
38+
l.warn(
39+
{
40+
key: 'oauth_signout:authjs_sign_out:error',
41+
error: serializeErrorForLog(error),
42+
},
43+
'Auth.js signOut() failed'
44+
)
45+
}
46+
47+
if (identityId) {
48+
await revokeKratosSessionsForIdentity(identityId)
49+
}
50+
51+
const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null
52+
if (!logoutUrl) {
53+
return NextResponse.redirect(postLogoutUrl)
54+
}
55+
56+
return NextResponse.redirect(logoutUrl.toString())
57+
}

0 commit comments

Comments
 (0)