Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,176 changes: 493 additions & 683 deletions bun.lock

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/app/api/auth/oauth/signed-out/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import {
getLogoutFinalUrl,
parseLogoutState,
} from '@/core/server/auth/ory/signout'

export async function GET(request: NextRequest) {
const options = parseLogoutState(request.nextUrl.searchParams.get('state'))

return NextResponse.redirect(
getLogoutFinalUrl(options, request.nextUrl.origin)
)
}
1 change: 1 addition & 0 deletions src/app/dashboard/account/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { isOryAuthEnabled } from '@/configs/flags'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { auth } from '@/core/server/auth'
import { getOrySignOutPath } from '@/core/server/auth/ory/signout'
import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team'
import { l } from '@/core/shared/clients/logger/logger'
import { encodedRedirect } from '@/lib/utils/auth'
Expand Down
1 change: 1 addition & 0 deletions src/app/dashboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TAB_URL_MAP } from '@/configs/dashboard-tab-url-map'
import { isOryAuthEnabled } from '@/configs/flags'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { auth } from '@/core/server/auth'
import { getOrySignOutPath } from '@/core/server/auth/ory/signout'
import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team'
import { l } from '@/core/shared/clients/logger/logger'
import { encodedRedirect } from '@/lib/utils/auth'
Expand Down
76 changes: 76 additions & 0 deletions src/app/oauth/consent/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import 'server-only'

import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getHydraOAuth2Api } from '@/core/server/auth/ory/hydra-admin'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'

// Hydra consent-provider endpoint.
//
// In normal operation this handler should never run: the OAuth2 client
// registration sets `skip_consent: true`, which makes Hydra auto-accept
// consent server-side and bypass the browser redirect entirely. We keep
// the handler implemented anyway so:
// - a misconfigured client (no skip_consent) still completes the flow
// instead of dead-ending at 404,
// - operators have a single place to plug in real consent UI later
// without re-shaping route paths.
//
// The implementation grants the full set of scopes Hydra asked for. This
// matches "machine-trusted client" semantics — appropriate for a
// first-party dashboard, never for a third-party app.
export async function GET(request: NextRequest) {
const challenge = request.nextUrl.searchParams.get('consent_challenge')
if (!challenge) {
return new NextResponse('missing consent_challenge', { status: 400 })
}

const hydra = getHydraOAuth2Api()

try {
const consentRequest = await hydra.getOAuth2ConsentRequest({
consentChallenge: challenge,
})

const { redirect_to } = await hydra.acceptOAuth2ConsentRequest({
consentChallenge: challenge,
acceptOAuth2ConsentRequest: {
// Echo back exactly what Hydra asked for. Granting a superset
// would let a client silently widen its scope on every login.
grant_scope: consentRequest.requested_scope ?? [],
grant_access_token_audience:
consentRequest.requested_access_token_audience ?? [],
// Remember so subsequent flows for the same subject+client skip
// this round-trip; lines up with the 3600s remember_for in the
// login handler.
remember: true,
remember_for: 3600,
},
})

l.info(
{
key: 'oauth_consent:accepted',
context: {
client_id: consentRequest.client?.client_id,
subject: consentRequest.subject,
grant_scope: consentRequest.requested_scope,
},
},
'auto-accepted Hydra consent challenge'
)

return NextResponse.redirect(redirect_to)
} catch (error) {
l.error(
{
key: 'oauth_consent:accept_failed',
error: serializeErrorForLog(error),
},
'failed to accept Hydra consent challenge'
)
return new NextResponse('failed to accept consent challenge', {
status: 502,
})
}
}
91 changes: 91 additions & 0 deletions src/app/oauth/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'server-only'

import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getHydraOAuth2Api } from '@/core/server/auth/ory/hydra-admin'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'

// Hydra login-provider endpoint.
//
// Hydra redirects the browser here with `?login_challenge=...` whenever an
// OAuth2 authorization flow starts and the user is not already
// authenticated against Hydra's *own* session cookie. The handler must:
// 1. fetch the login request from Hydra's admin API (validates the
// challenge and tells us if Hydra already has a session for this
// subject — `skip === true`),
// 2. accept the request with a subject identifier,
// 3. redirect the browser to the URL Hydra returns.
//
// This implementation auto-accepts every challenge as a fixed dev subject
// (`ORY_LOCAL_LOGIN_SUBJECT`). It is intended for local/dev deployments
// only: in production the login UI is owned by a real IdP (Kratos / Ory
// Network) and the dashboard is not registered as Hydra's login provider.
//
// Modeled on ory/hydra-login-consent-node `src/routes/login.ts`.
export async function GET(request: NextRequest) {
const challenge = request.nextUrl.searchParams.get('login_challenge')
if (!challenge) {
return new NextResponse('missing login_challenge', { status: 400 })
}

const subject = process.env.ORY_LOCAL_LOGIN_SUBJECT
if (!subject) {
l.error(
{ key: 'oauth_login:misconfigured' },
'ORY_LOCAL_LOGIN_SUBJECT must be set when the dashboard acts as Hydra login provider'
)
return new NextResponse('login provider is not configured', {
status: 500,
})
}

const hydra = getHydraOAuth2Api()

try {
// Pre-fetch the login request. We don't strictly need its body to
// accept (the challenge alone is enough), but the round-trip lets us
// surface "challenge expired / not found" as a 404 from Hydra before
// we try to accept it, and gives us `skip` for forward-compat (today
// we accept either way, but logging the branch is useful).
const loginRequest = await hydra.getOAuth2LoginRequest({
loginChallenge: challenge,
})

const { redirect_to } = await hydra.acceptOAuth2LoginRequest({
loginChallenge: challenge,
acceptOAuth2LoginRequest: {
// Subject == OIDC `sub` claim. Stable per "user" — in this
// single-user dev mode there is only one possible value.
subject,
// Remember the Hydra session for an hour so subsequent OAuth2
// flows hit the `skip` fast path and don't bounce through this
// handler again until expiry.
remember: true,
remember_for: 3600,
},
})

l.info(
{
key: 'oauth_login:accepted',
context: {
subject,
skip: loginRequest.skip,
client_id: loginRequest.client?.client_id,
},
},
'auto-accepted Hydra login challenge'
)

return NextResponse.redirect(redirect_to)
} catch (error) {
l.error(
{
key: 'oauth_login:accept_failed',
error: serializeErrorForLog(error),
},
'failed to accept Hydra login challenge'
)
return new NextResponse('failed to accept login challenge', { status: 502 })
}
}
49 changes: 49 additions & 0 deletions src/app/oauth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'server-only'

import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getHydraOAuth2Api } from '@/core/server/auth/ory/hydra-admin'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'

// Hydra logout-provider endpoint.
//
// Hydra redirects the browser here with `?logout_challenge=...` after the
// dashboard initiates RP-initiated logout (POST /oauth2/sessions/logout
// in src/app/api/auth/oauth/signout-flow/route.ts). We accept the
// challenge unconditionally — in single-user dev mode there is no
// "confirm sign out?" UI to show, and the dashboard has already cleared
// its own Auth.js session before redirecting to Hydra.
//
// Modeled on the logout half of ory/hydra-login-consent-node.
export async function GET(request: NextRequest) {
const challenge = request.nextUrl.searchParams.get('logout_challenge')
if (!challenge) {
return new NextResponse('missing logout_challenge', { status: 400 })
}

const hydra = getHydraOAuth2Api()

try {
const { redirect_to } = await hydra.acceptOAuth2LogoutRequest({
logoutChallenge: challenge,
})

l.info(
{ key: 'oauth_logout:accepted' },
'auto-accepted Hydra logout challenge'
)

return NextResponse.redirect(redirect_to)
} catch (error) {
l.error(
{
key: 'oauth_logout:accept_failed',
error: serializeErrorForLog(error),
},
'failed to accept Hydra logout challenge'
)
return new NextResponse('failed to accept logout challenge', {
status: 502,
})
}
}
2 changes: 1 addition & 1 deletion src/core/modules/sandboxes/lifecycle-event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ type SandboxLifecycleEventType = z.infer<typeof SandboxLifecycleEventTypeSchema>

export {
SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX,
SandboxLifecycleEventTypeSchema,
type SandboxLifecycleEventType,
SandboxLifecycleEventTypeSchema,
}
14 changes: 14 additions & 0 deletions src/core/server/actions/ory-auth-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use server'

import { signIn } from '@/auth'

// thin wrapper around Auth.js's signIn() that exists so client components
// can submit a form to it. signIn() throws a redirect; never returns normally.
export async function signInWithOryAction(formData: FormData) {
const returnTo = formData.get('returnTo')
const redirectTo =
typeof returnTo === 'string' && returnTo.length > 0
? returnTo
: '/dashboard'
await signIn('ory', { redirectTo })
}
Loading