Skip to content

Commit ce6ef5c

Browse files
feat: add Hydra OAuth flow for CLI authentication (#463)
## Summary Adds a public OAuth2 client flow for CLI token issuance. CLI versions >= 2.12.2 use PKCE-based OAuth with Hydra's public client instead of the legacy e2b access token flow. The dashboard acts as consent provider for both the dashboard and CLI OAuth clients. Paired with: E2B CLI PR replacing access token auth with Hydra OAuth flow --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 5bb9262 commit ce6ef5c

12 files changed

Lines changed: 532 additions & 65 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ ORY_SDK_URL=https://your-project.projects.oryapis.com
1616
# ORY_HYDRA_PUBLIC_URL=http://localhost:4444
1717
ORY_OAUTH2_CLIENT_ID=your_ory_oauth2_client_id
1818
ORY_OAUTH2_CLIENT_SECRET=your_ory_oauth2_client_secret
19+
### OAuth2 public client for CLI token issuance (no secret needed)
20+
ORY_OAUTH2_CLI_CLIENT_ID=your_cli_public_client_id
1921
### Access-token audience requested from Ory. Must match the backend JWT audience configuration.
2022
ORY_OAUTH2_AUDIENCE=https://api.e2b.dev
2123
### Ory project admin API token used for IdentityApi lookups

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ jobs:
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
52+
ORY_OAUTH2_CLI_CLIENT_ID: test-ory-cli-client-id
5253
ORY_OAUTH2_AUDIENCE: https://api.e2b-test.dev
5354
ORY_PROJECT_API_TOKEN: test-ory-project-api-token
5455
DASHBOARD_API_ADMIN_TOKEN: test-dashboard-admin-token

src/app/(auth)/auth/cli/page.tsx

Lines changed: 111 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,49 @@
11
import { redirect } from 'next/navigation'
22
import { Suspense } from 'react'
33
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
4+
import type { FeatureFlagContext } from '@/core/modules/feature-flags/context'
5+
import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server'
46
import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server'
57
import { getAuthContext } from '@/core/server/auth'
68
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
79
import { isLoopbackUrl } from '@/core/shared/schemas/url'
810
import { encodedRedirect } from '@/lib/utils/auth'
911
import { generateE2BUserAccessToken } from '@/lib/utils/server'
12+
import { isVersionGreaterOrEqual } from '@/lib/utils/version'
1013
import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert'
1114
import { CloudIcon, LaptopIcon, LinkIcon } from '@/ui/primitives/icons'
1215

13-
// Types
16+
// Minimum CLI version that supports the OAuth JWT auth flow.
17+
// CLI versions >= this use the new public-client OAuth flow;
18+
// older versions fall back to the legacy e2b access token flow.
19+
// Old binaries (pre-2.13.0) don't send cliVersion at all (it's a new param),
20+
// so they correctly fall to 'legacy'.
21+
const MIN_CLI_VERSION_FOR_HYDRA_FLOW = '2.13.0'
22+
1423
type CLISearchParams = Promise<{
1524
next?: string
25+
cliVersion?: string
1626
state?: string
1727
error?: string
1828
}>
1929

20-
// Server Actions
30+
type CLIFlow = 'hydra' | 'legacy'
31+
32+
function resolveCLIFlow(cliVersion: string | undefined): CLIFlow {
33+
if (!cliVersion) return 'legacy'
34+
return isVersionGreaterOrEqual(cliVersion, MIN_CLI_VERSION_FOR_HYDRA_FLOW)
35+
? 'hydra'
36+
: 'legacy'
37+
}
2138

22-
async function handleCLIAuth(
39+
function handleHydraCLIAuth(next: string) {
40+
if (!isLoopbackUrl(next)) {
41+
throw new Error('Invalid redirect URL')
42+
}
43+
return redirect(`/api/auth/oauth/cli-start?next=${encodeURIComponent(next)}`)
44+
}
45+
46+
async function handleLegacyCLIAuth(
2347
next: string,
2448
userEmail: string,
2549
authProviderAccessToken: string
@@ -57,10 +81,9 @@ async function handleCLIAuth(
5781
return redirect(`${next}?${searchParams.toString()}`)
5882
}
5983

60-
// UI Components
6184
function CLIIcons() {
6285
return (
63-
<p className="flex items-center justify-center gap-4 text-3xl tracking-tight sm:text-4xl">
86+
<p className="flex items-center justify-center gap-4 text-3xl tracking-tight sm:text-4xl">
6487
<span className="text-fg-tertiary">
6588
<LaptopIcon className="size-8" />
6689
</span>
@@ -86,98 +109,123 @@ function ErrorAlert({ message }: { message: string }) {
86109
function SuccessState() {
87110
return (
88111
<>
89-
<h2 className="text-brand-400 ">Successfully linked</h2>
112+
<h2 className="text-brand-400">Successfully linked</h2>
90113
<div>You can close this page and start using CLI.</div>
91114
</>
92115
)
93116
}
94117

95-
// Main Component
96118
export default async function CLIAuthPage({
97119
searchParams,
98120
}: {
99121
searchParams: CLISearchParams
100122
}) {
101-
const { next, state, error } = await searchParams
123+
const { next, cliVersion, state, error } = await searchParams
102124
const authContext = await getAuthContext()
103125

104126
if (state === 'success') {
105127
return <SuccessState />
106128
}
107129

108-
// Validate redirect URL
109130
if (!next || !isLoopbackUrl(next)) {
110131
l.error(
111132
{
112133
key: 'cli_auth:invalid_redirect_url',
113134
user_id: authContext?.user.id,
114-
context: {
115-
next,
116-
},
135+
context: { next },
117136
},
118137
`Invalid redirect URL: ${next}`
119138
)
120139
redirect(PROTECTED_URLS.DASHBOARD)
121140
}
122141

123-
// If user is not authenticated, redirect to sign in with return URL
124142
if (!authContext) {
125-
const searchParams = new URLSearchParams({
126-
returnTo: `${AUTH_URLS.CLI}?${new URLSearchParams({ next }).toString()}`,
127-
})
128-
redirect(`${AUTH_URLS.SIGN_IN}?${searchParams.toString()}`)
143+
const returnToParams = new URLSearchParams({ next })
144+
if (cliVersion) returnToParams.set('cliVersion', cliVersion)
145+
return redirect(
146+
`${AUTH_URLS.SIGN_IN}?returnTo=${encodeURIComponent(
147+
`${AUTH_URLS.CLI}?${returnToParams.toString()}`
148+
)}`
149+
)
129150
}
130151

131-
// Handle CLI callback if authenticated
132-
if (!error && next && authContext) {
133-
try {
134-
if (!authContext.user.email) {
135-
throw new Error('No user email found')
136-
}
137-
138-
return await handleCLIAuth(
139-
next,
140-
authContext.user.email,
141-
authContext.accessToken
142-
)
143-
} catch (err) {
144-
if (err instanceof Error && err.message.includes('NEXT_REDIRECT')) {
145-
throw err
146-
}
147-
148-
l.error(
149-
{
150-
key: 'cli_auth:unexpected_error',
151-
error: serializeErrorForLog(err),
152-
user_id: authContext.user.id,
153-
context: {
154-
next,
155-
},
156-
},
157-
`Unexpected error during CLI authentication: ${err instanceof Error ? err.message : String(err)}`
158-
)
152+
const flow = resolveCLIFlow(cliVersion)
159153

160-
return encodedRedirect('error', '/auth/cli', (err as Error).message, {
161-
next,
162-
})
154+
if (flow === 'legacy') {
155+
const flagContext: FeatureFlagContext = {
156+
user: {
157+
id: authContext.user.id,
158+
email: authContext.user.email ?? undefined,
159+
},
160+
}
161+
const tokenProvisioningDisabled = await featureFlags.isEnabled(
162+
'disableE2BAccessTokenProvisioning',
163+
flagContext
164+
)
165+
166+
if (tokenProvisioningDisabled) {
167+
const errorUrl = new URL(next)
168+
errorUrl.searchParams.set(
169+
'error',
170+
'CLI update required. Run: npm install -g @e2b/cli@latest'
171+
)
172+
return redirect(errorUrl.toString())
163173
}
164174
}
165175

166-
return (
167-
<div className="p-6 text-center">
168-
<CLIIcons />
169-
<h2 className="mt-6 text-base leading-7">
170-
Linking CLI with your account
171-
</h2>
172-
<div className="text-fg-tertiary mt-12 leading-8">
173-
<Suspense fallback={<div>Loading...</div>}>
174-
{error ? (
176+
if (error) {
177+
return (
178+
<div className="p-6 text-center">
179+
<CLIIcons />
180+
<h2 className="mt-6 text-base leading-7">
181+
Linking CLI with your account
182+
</h2>
183+
<div className="text-fg-tertiary mt-12 leading-8">
184+
<Suspense fallback={<div>Loading...</div>}>
175185
<ErrorAlert message={error} />
176-
) : (
177-
<div>Authorizing CLI...</div>
178-
)}
179-
</Suspense>
186+
</Suspense>
187+
</div>
180188
</div>
181-
</div>
182-
)
189+
)
190+
}
191+
192+
try {
193+
if (flow === 'hydra') {
194+
return handleHydraCLIAuth(next)
195+
}
196+
197+
if (!authContext.user.email) {
198+
throw new Error('No user email found')
199+
}
200+
201+
return await handleLegacyCLIAuth(
202+
next,
203+
authContext.user.email,
204+
authContext.accessToken
205+
)
206+
} catch (err) {
207+
if (err instanceof Error && err.message.includes('NEXT_REDIRECT')) {
208+
throw err
209+
}
210+
211+
l.error(
212+
{
213+
key: 'cli_auth:unexpected_error',
214+
error: serializeErrorForLog(err),
215+
user_id: authContext.user.id,
216+
context: { next, flow },
217+
},
218+
`Unexpected error during CLI authentication: ${err instanceof Error ? err.message : String(err)}`
219+
)
220+
221+
const redirectParams: Record<string, string> = { next }
222+
if (cliVersion) redirectParams.cliVersion = cliVersion
223+
224+
return encodedRedirect(
225+
'error',
226+
'/auth/cli',
227+
(err as Error).message,
228+
redirectParams
229+
)
230+
}
183231
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import 'server-only'
2+
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { getAuthContext } from '@/core/server/auth'
5+
import {
6+
buildRevokeEndpoint,
7+
buildTokenEndpoint,
8+
CLI_OAUTH_CALLBACK_PATH,
9+
CLI_OAUTH_FLOW_COOKIE,
10+
exchangeCliCallback,
11+
openCliFlowState,
12+
readCliOAuthEnv,
13+
} from '@/core/server/auth/ory/cli-oauth'
14+
import { isLoopbackUrl } from '@/core/shared/schemas/url'
15+
16+
// Hydra redirects here with ?code after SSO. We exchange the code (validating
17+
// state + PKCE), then redirect to the CLI's localhost with the tokens.
18+
// The user must have an active Kratos session — Hydra used SSO to issue the
19+
// code without a login prompt.
20+
export async function GET(request: NextRequest) {
21+
const origin = request.nextUrl.origin
22+
23+
const flow = await openCliFlowState(
24+
request.cookies.get(CLI_OAUTH_FLOW_COOKIE)?.value
25+
)
26+
27+
if (!flow || !isLoopbackUrl(flow.next)) {
28+
return finalize(
29+
new NextResponse('Invalid or expired flow state', { status: 400 }),
30+
origin
31+
)
32+
}
33+
34+
const authContext = await getAuthContext()
35+
if (!authContext) {
36+
return finalize(
37+
redirectWithParams(flow.next, { error: 'Not authenticated' }),
38+
origin
39+
)
40+
}
41+
42+
if (!authContext.user.email) {
43+
return finalize(
44+
redirectWithParams(flow.next, { error: 'No user email found' }),
45+
origin
46+
)
47+
}
48+
49+
let env: ReturnType<typeof readCliOAuthEnv>
50+
try {
51+
env = readCliOAuthEnv()
52+
} catch (error) {
53+
return finalize(
54+
redirectWithParams(flow.next, {
55+
error: `OAuth configuration error: ${error instanceof Error ? error.message : String(error)}`,
56+
}),
57+
origin
58+
)
59+
}
60+
const redirectUri = `${origin}${CLI_OAUTH_CALLBACK_PATH}`
61+
62+
let tokens: Awaited<ReturnType<typeof exchangeCliCallback>>
63+
try {
64+
tokens = await exchangeCliCallback({
65+
currentUrl: new URL(request.url),
66+
expectedState: flow.state,
67+
codeVerifier: flow.codeVerifier,
68+
redirectUri,
69+
})
70+
} catch (error) {
71+
return finalize(
72+
redirectWithParams(flow.next, {
73+
error: `Token exchange failed: ${error instanceof Error ? error.message : String(error)}`,
74+
}),
75+
origin
76+
)
77+
}
78+
79+
const tokenEndpoint = buildTokenEndpoint(env.issuer.href)
80+
const revokeEndpoint = buildRevokeEndpoint(env.issuer.href)
81+
82+
return finalize(
83+
redirectWithParams(flow.next, {
84+
email: authContext.user.email,
85+
accessToken: tokens.accessToken,
86+
refreshToken: tokens.refreshToken ?? '',
87+
tokenEndpoint: tokenEndpoint,
88+
revokeEndpoint: revokeEndpoint,
89+
clientId: env.clientId,
90+
}),
91+
origin
92+
)
93+
}
94+
95+
function redirectWithParams(
96+
next: string,
97+
params: Record<string, string>
98+
): NextResponse {
99+
const url = new URL(next)
100+
for (const [key, value] of Object.entries(params)) {
101+
url.searchParams.set(key, value)
102+
}
103+
return NextResponse.redirect(url)
104+
}
105+
106+
function finalize(response: NextResponse, _origin: string): NextResponse {
107+
response.cookies.delete(CLI_OAUTH_FLOW_COOKIE)
108+
return response
109+
}

0 commit comments

Comments
 (0)