Skip to content

Commit 3988b78

Browse files
refactor: Separate OIDC authentication flows for web and CLI clients
1 parent 343131d commit 3988b78

31 files changed

Lines changed: 1713 additions & 370 deletions

.env.local

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,8 @@ OIDC_ALLOW_INSECURE=true
1717
# Redis Configuration
1818
REDIS_HOST=localhost
1919
REDIS_PORT=6379
20-
REDIS_DB=0
20+
REDIS_DB=0
21+
22+
# Frontend UI Configuration
23+
FRONTEND_URL=http://localhost:5173
24+
COOKIE_SECURE=false

.env.test

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ MAILPIT_API_ENDPOINT=localhost:8026
3030
OIDC_ISSUER_URL=http://localhost:4011
3131
OIDC_CLIENT_ID=integration-test-client-id
3232
OIDC_CLIENT_SECRET=integration-test-client-secret
33-
OIDC_REDIRECT_URI=http://localhost:3000/auth/callback
33+
OIDC_REDIRECT_URI=http://localhost:3000/auth/web/callback
3434
OIDC_ALLOW_INSECURE=true
3535

3636
# Redis Configuration
@@ -39,4 +39,8 @@ REDIS_PORT=6380
3939
REDIS_DB=0
4040

4141
# Wiremock Configuration
42-
WIREMOCK_BASE_URL=http://localhost:9090
42+
WIREMOCK_BASE_URL=http://localhost:9090
43+
44+
# Frontend UI Configuration
45+
FRONTEND_URL=http://localhost:5173
46+
COOKIE_SECURE=false
Lines changed: 15 additions & 233 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,18 @@
1-
import {
2-
Controller,
3-
Get,
4-
Post,
5-
Res,
6-
Logger,
7-
Query,
8-
Body,
9-
HttpCode,
10-
BadRequestException,
11-
Headers,
12-
Req
13-
} from "@nestjs/common"
14-
import {Response, Request} from "express"
1+
import {Controller, Get, Post, Body, HttpCode, Logger, Headers, Req} from "@nestjs/common"
2+
import {Request} from "express"
153
import {AuthService, GenerateChallengeRequest, IdentityService} from "@services"
164
import {isLeft} from "fp-ts/lib/Either"
175
import * as TE from "fp-ts/TaskEither"
186
import {PublicRoute} from "../../../main/src/auth/jwt.authguard"
197
import {GetAuthenticatedEntity} from "../../../main/src/auth"
208
import {
21-
TokenRequest,
229
TokenResponse,
23-
SuccessfulAuthResponse,
24-
FailedAuthResponse,
2510
AgentChallengeRequest,
2611
AgentChallengeResponse,
2712
AgentTokenResponse,
2813
RefreshTokenRequest,
2914
AgentTokenRequest,
30-
GetUserInfo200Response,
31-
PrivilegedTokenResponse
15+
GetUserInfo200Response
3216
} from "@approvio/api"
3317
import {
3418
mapAgentChallengeRequestToService,
@@ -41,90 +25,26 @@ import {
4125
} from "./agent-auth.mappers"
4226
import {pipe} from "fp-ts/lib/function"
4327
import {AuthenticatedEntity} from "@domain"
44-
import {generateErrorPayload} from "@controllers/error"
45-
import {
46-
validateGenerateTokenRequest,
47-
validateRefreshAgentTokenRequest,
48-
validateRefreshTokenRequest,
49-
validateExchangePrivilegeTokenRequest
50-
} from "./auth.validators"
28+
import {validateRefreshAgentTokenRequest} from "./auth.validators"
5129
import {
52-
generateErrorResponseForGenerateToken,
5330
generateErrorResponseForRefreshAgentToken,
54-
generateErrorResponseForRefreshUserToken,
5531
generateErrorResponseForEntityInfo,
5632
mapToTokenResponse,
57-
mapToEntityInfoResponse,
58-
generateErrorResponseForExchangePrivilegeToken,
59-
mapToPrivilegeTokenExchange
33+
mapToEntityInfoResponse
6034
} from "./auth.mappers"
6135
import {logSuccess} from "@utils"
62-
import {HttpStatusCode} from "axios"
6336

6437
/**
65-
* ┌─────────────────────────────────────────────────────────────────────────────────────────┐
66-
* │ OIDC Authentication HTTP Endpoints Flow │
67-
* │ (Load Balancer Compatible) │
68-
* ├─────────────────────────────────────────────────────────────────────────────────────────┤
69-
* │ │
70-
* │ Frontend Load Balancer Backend A/B OIDC Provider Database │
71-
* │ │ │ │ │ │ │
72-
* │ 1. GET /auth/login │
73-
* │ │ ──────────────┼────────────────►│ Generate PKCE │ │ │
74-
* │ │ │ │ challenge │ │ │
75-
* │ │ │ │ + Auth URL │ │ │
76-
* │ │ │ │ │ │ │
77-
* │ │ │ │ Store PKCE ──────┼───────────────►│ Session │
78-
* │ │ │ │ {state, codeV} │ │ Storage │
79-
* │ │ │ │ │ │ │
80-
* │ │ 302 Redirect │ ◄───────────────│ Redirect to │ │ │
81-
* │ │ to OIDC │ │ OIDC Provider │ │ │
82-
* │ │ │ │ │ │ │
83-
* │ │ │ │ │ │ │
84-
* │ 2. User Authentication │
85-
* │ │ ──────────────────────────────────────────────────►│ User login │ │
86-
* │ │ │ │ │ & consent │ │
87-
* │ │ │ │ │ │ │
88-
* │ 3. GET /auth/callback?code=abc&state=xyz │
89-
* │ │ ◄──────────────────────────────────────────────────┤ Authorization │ │
90-
* │ │ │ │ │ code callback │ │
91-
* │ │ │ │ │ │ │
92-
* │ │ ──────────────┼────────────────►│ Any server can │ │ │
93-
* │ │ │ │ handle callback │ │ │
94-
* │ │ │ │ │ │ │
95-
* │ │ 302 Redirect │ ◄───────────────│ Redirect to │ │ │
96-
* │ │ to success │ │ /success?code=.. │ │ │
97-
* │ │ │ │ │ │ │
98-
* │ │ │ │ │ │ │
99-
* │ 4. POST /auth/token {code, state} │
100-
* │ │ ──────────────┼────────────────►│ Retrieve PKCE ◄──┼────────────────┤ Lookup │
101-
* │ │ │ │ {codeVerifier} │ │ by state │
102-
* │ │ │ │ │ │ │
103-
* │ │ │ │ Exchange code ──►│ Token Exchange │ │
104-
* │ │ │ │ + codeVerifier │ │ │
105-
* │ │ │ │ │ │ │
106-
* │ │ │ │ Get user info ──►│ User Claims │ │
107-
* │ │ │ │ (basic claims) │ │ │
108-
* │ │ │ │ │ │ │
109-
* │ │ │ │ JIT Provision ◄──┼────────────────┤ Find or │
110-
* │ │ │ │ (Find/Create) │ │ Create │
111-
* │ │ │ │ │ │ │
112-
* │ │ App JWT │ ◄───────────────│ Generate JWT │ │ │
113-
* │ │ w/ orgRole │ │ payload │ │ │
114-
* │ │
115-
* │ App JWT Payload: │
116-
* │ { │
117-
* │ "sub": "user-uuid", // OIDC standard │
118-
* │ "email": "user@example.com", // OIDC standard │
119-
* │ "name": "John Doe", // OIDC standard │
120-
* │ "orgRole": "admin" // Extended: admin/member │
121-
* │ } │
122-
* │ │
123-
* │ Load Balancer Benefits: │
124-
* │ • PKCE data stored in shared database - any server can access │
125-
* │ • Stateless authentication - no server affinity required │
126-
* │ • App JWT includes organizational context derived during JIT provisioning │
127-
* └─────────────────────────────────────────────────────────────────────────────────────────┘
38+
* OIDC Authentication HTTP Endpoints Flow
39+
*
40+
* The authentication flows (Web, CLI, and Agents) are documented in detail
41+
* with Mermaid diagrams in `docs/authentication.md`.
42+
*
43+
* This controller handles agent/machine-to-machine authentication and
44+
* the shared /auth/info endpoint.
45+
*
46+
* - Web endpoints (including login initiation) are handled in `WebAuthController` (/auth/web/*)
47+
* - CLI endpoints are handled in `CliAuthController` (/auth/cli/*)
12848
*/
12949
@Controller("auth")
13050
export class AuthController {
@@ -133,79 +53,6 @@ export class AuthController {
13353
private readonly identityService: IdentityService
13454
) {}
13555

136-
@PublicRoute()
137-
@HttpCode(HttpStatusCode.Found)
138-
@Get("login")
139-
async login(@Res() res: Response): Promise<void> {
140-
const result = await pipe(
141-
this.authService.initiateOidcLogin(),
142-
logSuccess("OIDC login initiated", "AuthController")
143-
)()
144-
145-
if (isLeft(result)) {
146-
Logger.error("Failed to initiate OIDC login", result.left)
147-
res.redirect("/auth/error")
148-
return
149-
}
150-
151-
Logger.debug(`Redirecting to OIDC provider: ${result.right}`)
152-
res.redirect(result.right)
153-
}
154-
155-
@PublicRoute()
156-
@Get("callback")
157-
async callback(@Query("code") code: string, @Query("state") state: string, @Res() res: Response): Promise<void> {
158-
if (!code || !state) {
159-
Logger.error("Missing code or state in OIDC callback")
160-
res.redirect("/auth/error")
161-
return
162-
}
163-
164-
// Redirect to success page with code and state for frontend to exchange for JWT
165-
res.redirect(`/auth/success?code=${code}&state=${state}`)
166-
}
167-
168-
@PublicRoute()
169-
@Post("token")
170-
async generateToken(@Body() body: TokenRequest): Promise<TokenResponse> {
171-
const runOidcLogin = (req: TokenRequest) => this.authService.completeOidcLogin(req.code, req.state)
172-
173-
const result = await pipe(
174-
body,
175-
TE.right,
176-
TE.chainW(raw => TE.fromEither(validateGenerateTokenRequest(raw))),
177-
TE.chainW(validated => runOidcLogin(validated)),
178-
TE.map(mapToTokenResponse),
179-
logSuccess("Token generated", "AuthController")
180-
)()
181-
182-
if (isLeft(result)) {
183-
Logger.error("OIDC login completion failed", result.left)
184-
throw generateErrorResponseForGenerateToken(result.left, "Failed to generate token")
185-
}
186-
187-
return result.right
188-
}
189-
190-
@PublicRoute()
191-
@Get("success")
192-
async success(@Query("code") code: string, @Query("state") state: string): Promise<SuccessfulAuthResponse> {
193-
if (!code) throw new BadRequestException(generateErrorPayload("MISSING_CODE", "missing code"))
194-
if (!state) throw new BadRequestException(generateErrorPayload("MISSING_STATE", "missing state"))
195-
return {
196-
message: "Authentication successful. Use the code and state to generate a JWT token.",
197-
code: code,
198-
state: state,
199-
b64encoded: Buffer.from(`${code}:${state}`, "utf-8").toString("base64")
200-
}
201-
}
202-
203-
@PublicRoute()
204-
@Get("error")
205-
async error(): Promise<FailedAuthResponse> {
206-
return {message: "Authentication failed. Please try again."}
207-
}
208-
20956
@Get("info")
21057
async getEntityInfo(
21158
@GetAuthenticatedEntity() authenticatedEntity: AuthenticatedEntity
@@ -265,29 +112,6 @@ export class AuthController {
265112
return result.right
266113
}
267114

268-
@PublicRoute()
269-
@Post("refresh")
270-
@HttpCode(200)
271-
async refreshUserToken(@Body() body: RefreshTokenRequest): Promise<TokenResponse> {
272-
const refreshUserToken = (refreshToken: string) => this.authService.refreshTokenForUser(refreshToken)
273-
274-
const result = await pipe(
275-
body,
276-
TE.right,
277-
TE.chainW(rawBody => TE.fromEither(validateRefreshTokenRequest(rawBody))),
278-
TE.chainW(validatedBody => refreshUserToken(validatedBody.refreshToken)),
279-
TE.map(serviceResult => mapToTokenResponse(serviceResult)),
280-
logSuccess("User token refreshed", "AuthController")
281-
)()
282-
283-
if (isLeft(result)) {
284-
Logger.error("User token refresh failed", result.left)
285-
throw generateErrorResponseForRefreshUserToken(result.left, "Failed to refresh token")
286-
}
287-
288-
return result.right
289-
}
290-
291115
@PublicRoute()
292116
@Post("agents/refresh")
293117
@HttpCode(200)
@@ -318,46 +142,4 @@ export class AuthController {
318142

319143
return result.right
320144
}
321-
322-
@PublicRoute()
323-
@HttpCode(HttpStatusCode.Found)
324-
@Get("initiatePrivilegedTokenExchange")
325-
async initiatePrivilegeToken(@Res() res: Response): Promise<void> {
326-
const runInitiation = () => this.authService.initiatePrivilegeTokenGeneration()
327-
328-
const result = await pipe(runInitiation(), logSuccess("Privilege token initiation started", "AuthController"))()
329-
330-
if (isLeft(result)) {
331-
Logger.error("Failed to initiate privilege token generation", result.left)
332-
res.redirect("/auth/error")
333-
return
334-
}
335-
336-
Logger.debug(`Redirecting to IDP for step-up: ${result.right}`)
337-
res.redirect(result.right)
338-
}
339-
340-
@Post("exchangePrivilegedToken")
341-
@HttpCode(200)
342-
async exchangePrivilegeToken(
343-
@Body() body: unknown,
344-
@GetAuthenticatedEntity() requestor: AuthenticatedEntity
345-
): Promise<PrivilegedTokenResponse> {
346-
const result = await pipe(
347-
body,
348-
TE.right,
349-
TE.chainW(rawBody => TE.fromEither(validateExchangePrivilegeTokenRequest(rawBody))),
350-
TE.chainEitherKW(mapToPrivilegeTokenExchange),
351-
TE.chainW(mappedRequest => this.authService.exchangePrivilegeToken(mappedRequest, requestor)),
352-
TE.map(accessToken => ({accessToken})),
353-
logSuccess("Privilege token exchanged", "AuthController")
354-
)()
355-
356-
if (isLeft(result)) {
357-
Logger.error("Privilege token exchange failed", result.left)
358-
throw generateErrorResponseForExchangePrivilegeToken(result.left, "Failed to exchange privilege token")
359-
}
360-
361-
return result.right
362-
}
363145
}

app/controllers/src/auth/auth.mappers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export function generateErrorResponseForRefreshUserToken(error: RefreshUserToken
6363
case "auth_missing_email_from_oidc_provider":
6464
case "user_not_found":
6565
case "request_invalid_user_identifier":
66+
case "auth_invalid_redirect_uri":
6667
return new BadRequestException(generateErrorPayload(errorCode, `${context}: invalid request`))
6768
case "refresh_token_reuse_detected":
6869
case "refresh_token_concurrent_update":
@@ -206,6 +207,7 @@ export function generateErrorResponseForExchangePrivilegeToken(
206207
case "request_missing_operation":
207208
case "request_invalid_operation":
208209
case "user_not_found":
210+
case "auth_invalid_redirect_uri":
209211
return new BadRequestException(generateErrorPayload(errorCode, `${context}: invalid request`))
210212
case "auth_token_generation_failed":
211213
case "unknown_error":
@@ -315,6 +317,7 @@ export function generateErrorResponseForRefreshAgentToken(
315317
case "user_not_found":
316318
case "request_invalid_user_identifier":
317319
case "request_invalid_dpop_jkt":
320+
case "auth_invalid_redirect_uri":
318321
return new BadRequestException(generateErrorPayload(errorCode, `${context}: invalid request`))
319322
case "refresh_token_concurrent_update":
320323
case "refresh_token_reuse_detected":
@@ -518,6 +521,7 @@ export function generateErrorResponseForGenerateToken(error: GenerateTokenError,
518521
case "auth_missing_email_from_oidc_provider":
519522
case "oidc_unknown_error":
520523
case "unknown_error":
524+
case "auth_invalid_redirect_uri":
521525
return new InternalServerErrorException(generateErrorPayload("UNKNOWN_ERROR", `${context}: unknown error`))
522526
case "request_empty_body":
523527
case "request_missing_code":

0 commit comments

Comments
 (0)