1
21import { Inject, Injectable } from "@nestjs/common";
22import type { Request, Response } from "express";
23import { PrismaService } from "../../common/prisma.service.js";
24import { RedisService } from "../../common/redis.service.js";
25import { AuditService } from "../../common/audit.service.js";
26import { ApiError } from "../../common/errors.js";
27import { loadEnv } from "../../config/env.js";
28import { OidcConfigSchema } from "./schemas.js";
29import { SsoSecretsService } from "./sso-secrets.js";
30import { SsoProvisioningService } from "./provisioning.service.js";
31import { pkcePair, randomToken, resolveClaim, verifyIdToken, type Jwk } from "./oidc.js";
32
33const STATE_TTL_SECONDS = 600;
34const JWKS_TTL_SECONDS = 3600;
35
36@Injectable()
37export class OidcService {
38 constructor(
39 @Inject(PrismaService) private readonly prisma: PrismaService,
40 @Inject(RedisService) private readonly redis: RedisService,
41 @Inject(AuditService) private readonly audit: AuditService,
42 @Inject(SsoSecretsService) private readonly secrets: SsoSecretsService,
43 @Inject(SsoProvisioningService) private readonly provisioning: SsoProvisioningService
44 ) {}
45
46
52 private origin(): string {
53 const env = loadEnv();
54 return (env.SSO_ORIGIN || env.WEB_ORIGIN).replace(/\/$/, "");
55 }
56
57
58 private webOrigin(): string {
59 return loadEnv().WEB_ORIGIN.replace(/\/$/, "");
60 }
61
62
63 async authorize(orgId: string): Promise<{ url: string }> {
64 const provider = await this.prisma.ssoProvider.findUnique({ where: { organizationId: orgId } });
65 if (!provider || provider.type !== "OIDC") {
66 throw new ApiError("NOT_FOUND", 404, "Organization has no OIDC SSO provider.", [{ reason: "no_sso_provider" }]);
67 }
68 const config = OidcConfigSchema.parse(provider.config);
69 const state = randomToken();
70 const nonce = randomToken();
71 const { verifier, challenge } = pkcePair();
72 const redirectUri = config.redirectUri || `${this.origin()}/sso/oidc/${orgId}/callback`;
73 const r = this.redis.getRedis();
74 await r.set(
75 `sso:oidc:state:${state}`,
76 JSON.stringify({ orgId, nonce, verifier, createdAt: Date.now() }),
77 "EX",
78 STATE_TTL_SECONDS
79 );
80
81 const params = new URLSearchParams({
82 response_type: "code",
83 client_id: config.clientId,
84 redirect_uri: redirectUri,
85 scope: config.scope,
86 state,
87 nonce,
88 code_challenge: challenge,
89 code_challenge_method: "S256"
90 });
91 return { url: `${config.authorizationEndpoint}?${params.toString()}` };
92 }
93
94
95 async callback(
96 orgId: string,
97 body: { code?: string; state?: string },
98 req: Request,
99 res: Response
100 ): Promise<void> {
101 const redirectTo = (error: string) => {
102 const target = new URL(`${this.webOrigin()}/login`);
103 target.searchParams.set("error", error);
104 res.status(302).header("location", target.toString()).end();
105 };
106 const fail = async (reason: string): Promise<never> => {
107 await this.audit.audit(req, undefined, {
108 action: "sso.login_failed",
109 resourceType: "user",
110 resourceId: orgId,
111 metadata: { type: "oidc", reason }
112 });
113 redirectTo(`sso_oidc_${reason}`);
114 throw new ApiError("FORBIDDEN", 403, "OIDC sign-in failed.", [{ reason: `oidc_${reason}` }]);
115 };
116
117 const provider = await this.prisma.ssoProvider.findUnique({ where: { organizationId: orgId } });
118 if (!provider || provider.type !== "OIDC") {
119 await fail("no_sso_provider");
120 return;
121 }
122 const config = OidcConfigSchema.parse(provider.config);
123
124
125 if (!body.state) {
126 await fail("state_invalid");
127 return;
128 }
129 const stateKey = `sso:oidc:state:${body.state}`;
130 let stateJson: string | null = null;
131 try {
132 stateJson = await this.redis.getRedis().get(stateKey);
133 } catch {
134 stateJson = null;
135 }
136 if (!stateJson) {
137 await fail("state_invalid");
138 return;
139 }
140 let state: { orgId: string; nonce: string; verifier: string };
141 try {
142 state = JSON.parse(stateJson) as { orgId: string; nonce: string; verifier: string };
143 } catch {
144 await fail("state_invalid");
145 return;
146 }
147 if (state.orgId !== orgId) {
148 await fail("state_invalid");
149 return;
150 }
151
152 try {
153 await this.redis.getRedis().del(stateKey);
154 } catch {
155
156 await fail("state_replayed");
157 return;
158 }
159
160 if (!body.code) {
161 await fail("missing_code");
162 return;
163 }
164
165
166 const clientSecret = (await this.secrets.getSecrets(orgId)).clientSecret ?? "";
167 let idToken: string;
168 try {
169 const form = new URLSearchParams({
170 grant_type: "authorization_code",
171 code: body.code,
172 redirect_uri: config.redirectUri || `${this.origin()}/sso/oidc/${orgId}/callback`,
173 client_id: config.clientId,
174 client_secret: clientSecret,
175 code_verifier: state.verifier
176 });
177 const tokenRes = await fetch(config.tokenEndpoint, {
178 method: "POST",
179 headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
180 body: form.toString()
181 });
182 const tokenResponse = (await tokenRes.json()) as { id_token?: string };
183 if (!tokenResponse.id_token) throw new Error(`no id_token (status ${tokenRes.status})`);
184 idToken = tokenResponse.id_token;
185 } catch {
186 await fail("token_exchange_failed");
187 return;
188 }
189
190
191 const jwksUri = config.jwksUri || `${config.issuer}/.well-known/jwks.json`;
192 let keys: Jwk[] = [];
193 try {
194 const cacheKey = `sso:oidc:jwks:${orgId}`;
195 const cached = await this.redis.getRedis().get(cacheKey);
196 if (cached) {
197 keys = (JSON.parse(cached) as { keys: Jwk[] }).keys;
198 } else {
199 const jwksRes = await fetch(jwksUri, { headers: { accept: "application/json" } });
200 const jwksDoc = (await jwksRes.json()) as { keys: Jwk[] };
201 keys = jwksDoc.keys ?? [];
202 await this.redis.getRedis().set(cacheKey, JSON.stringify({ keys }), "EX", JWKS_TTL_SECONDS);
203 }
204 } catch {
205 await fail("invalid_id_token");
206 return;
207 }
208
209
210 const verified = verifyIdToken(idToken, { keys }, {
211 issuer: config.issuer,
212 clientId: config.clientId,
213 nonce: state.nonce
214 });
215 if (!verified.ok) {
216 await fail(verified.reason);
217 return;
218 }
219
220
221 const mapping = config.claimsMapping;
222 const emailRaw = mapping ? resolveClaim(verified.claims, mapping.email) : verified.claims.email;
223 const subRaw = mapping ? resolveClaim(verified.claims, mapping.sub) : verified.claims.sub;
224 const nameRaw = mapping ? resolveClaim(verified.claims, mapping.name) : verified.claims.name;
225 const email = typeof emailRaw === "string" ? emailRaw : "";
226 const externalId = typeof subRaw === "string" ? subRaw : email;
227 if (!email || !externalId) {
228 await fail("missing_claims");
229 return;
230 }
231
232 try {
233 await this.provisioning.provision({
234 req,
235 res,
236 organizationId: orgId,
237 provider: {
238 id: provider.id,
239 type: "OIDC",
240 name: provider.name,
241 config: provider.config as Record<string, unknown>
242 },
243 externalId,
244 email,
245 displayName: typeof nameRaw === "string" ? nameRaw : null,
246 claims: verified.claims
247 });
248 } catch (err) {
249 const detail = err instanceof ApiError ? (err.details?.[0]?.detail as string | undefined) : "provisioning_failed";
250 redirectTo(`sso_${detail ?? "provisioning_failed"}`);
251 return;
252 }
253
254
255 const target = this.webOrigin() + "/w/default";
256 res.status(302).header("location", target).end();
257 }
258}
259