Skip to content

Commit 252193c

Browse files
rap2hpoutrerevu-bot
andcommitted
feat: add domain verification on proconnect (#250)
Co-authored-by: Revu <[email protected]>
1 parent e5b1904 commit 252193c

File tree

11 files changed

+359
-75
lines changed

11 files changed

+359
-75
lines changed

web/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ NEXTAUTH_URL=http://localhost:3000
1313

1414
# Use "integration" for testing, "production" for prod
1515
PROCONNECT_ENV=integration
16+
NEXT_PUBLIC_PROCONNECT_ENV=integration
1617

1718
# Get these from ProConnect portal
1819
PROCONNECT_CLIENT_ID=

web/src/app/access-denied/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { AccessDeniedContent } from "@/modules/auth/AccessDeniedContent";
2+
3+
export default function AccessDeniedPage() {
4+
return <AccessDeniedContent />;
5+
}
6+

web/src/hooks/use-auth.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@ import { useSession } from "next-auth/react";
55
export function useAuth() {
66
const { data: session, status } = useSession();
77

8+
// A user is truly authenticated only if:
9+
// 1. They have a valid session (status === "authenticated")
10+
// 2. They are NOT marked as unauthorized
11+
const isAuthenticated =
12+
status === "authenticated" && !session?.unauthorized;
13+
814
return {
915
session,
1016
status,
11-
isAuthenticated: status === "authenticated",
17+
isAuthenticated,
1218
isLoading: status === "loading",
1319
user: session?.user,
20+
isUnauthorized: session?.unauthorized === true,
1421
};
1522
}

web/src/lib/auth/ProConnectProvider.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
2+
import * as jose from "jose";
23

34
export interface ProConnectProfile {
45
sub: string;
@@ -31,23 +32,16 @@ export default function ProConnectProvider<P extends ProConnectProfile>(
3132
? "https://auth.agentconnect.gouv.fr/api/v2/"
3233
: "https://fca.integ01.dev-agentconnect.fr/api/v2";
3334

34-
console.log("🔗 ProConnect Provider Configuration:");
35-
console.log(" Domain:", PROCONNECT_DOMAIN);
36-
console.log(
37-
" WellKnown:",
38-
`${PROCONNECT_DOMAIN}/.well-known/openid-configuration`
39-
);
40-
console.log(" Client ID:", options.clientId ? "✓ Set" : "✗ Missing");
41-
console.log(" Client Secret:", options.clientSecret ? "✓ Set" : "✗ Missing");
42-
4335
return {
4436
id: "proconnect",
4537
name: "ProConnect",
4638
type: "oauth",
4739
wellKnown: `${PROCONNECT_DOMAIN}/.well-known/openid-configuration`,
4840
authorization: {
4941
params: {
50-
scope: "openid email profile",
42+
// ProConnect requires individual scopes for each claim
43+
// See: https://partenaires.proconnect.gouv.fr/docs/fournisseur-service/scope-claims
44+
scope: "openid email given_name usual_name uid siret",
5145
acr_values: "eidas1", // Level of authentication required
5246
},
5347
},
@@ -56,8 +50,62 @@ export default function ProConnectProvider<P extends ProConnectProfile>(
5650
client: {
5751
token_endpoint_auth_method: "client_secret_post",
5852
},
53+
// ProConnect returns userinfo as a JWT signed with HS256 (using client_secret)
54+
// We need to manually fetch and verify it
55+
// See: https://partenaires.proconnect.gouv.fr/docs/fournisseur-service/scope-claims
56+
userinfo: {
57+
url: `${PROCONNECT_DOMAIN}/userinfo`,
58+
async request({ tokens }) {
59+
const response = await fetch(`${PROCONNECT_DOMAIN}/userinfo`, {
60+
headers: {
61+
Authorization: `Bearer ${tokens.access_token}`,
62+
},
63+
});
64+
65+
if (!response.ok) {
66+
throw new Error(`Userinfo request failed: ${response.status}`);
67+
}
68+
69+
// ProConnect returns userinfo as a signed JWT (HS256, RS256, or ES256)
70+
const jwt = await response.text();
71+
const header = jose.decodeProtectedHeader(jwt);
72+
73+
if (header.alg === "HS256") {
74+
// For HS256, verify with client_secret
75+
const secret = new TextEncoder().encode(options.clientSecret);
76+
const { payload } = await jose.jwtVerify(jwt, secret, {
77+
algorithms: ["HS256"],
78+
});
79+
return payload as unknown as P;
80+
} else if (header.alg === "RS256" || header.alg === "ES256") {
81+
// For RS256/ES256, verify with JWKS
82+
const jwksUrls = [
83+
`${PROCONNECT_DOMAIN}/jwks`,
84+
`${PROCONNECT_DOMAIN}/.well-known/jwks.json`,
85+
];
86+
87+
for (const jwksUrl of jwksUrls) {
88+
try {
89+
const JWKS = jose.createRemoteJWKSet(new URL(jwksUrl));
90+
const { payload } = await jose.jwtVerify(jwt, JWKS, {
91+
algorithms: ["RS256", "ES256"],
92+
});
93+
return payload as unknown as P;
94+
} catch {
95+
// Try next URL
96+
continue;
97+
}
98+
}
99+
100+
throw new Error(
101+
`Could not verify ${header.alg} JWT with available JWKS endpoints`
102+
);
103+
}
104+
105+
throw new Error(`Unsupported JWT algorithm: ${header.alg}`);
106+
},
107+
},
59108
profile(profile) {
60-
console.log("👤 Profile mapping:", profile);
61109
return {
62110
id: profile.sub,
63111
email: profile.email,

web/src/lib/auth/auth-options.ts

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,30 @@ import { NextAuthOptions } from "next-auth";
22
import ProConnectProvider, { ProConnectProfile } from "./ProConnectProvider";
33
import * as Sentry from "@sentry/nextjs";
44

5-
// Debug logging
6-
console.log("🔧 NextAuth Configuration:");
7-
console.log(" PROCONNECT_ENV:", process.env.PROCONNECT_ENV);
8-
console.log(
9-
" PROCONNECT_CLIENT_ID:",
10-
process.env.PROCONNECT_CLIENT_ID ? "✓ Set" : "✗ Missing"
11-
);
12-
console.log(
13-
" PROCONNECT_CLIENT_SECRET:",
14-
process.env.PROCONNECT_CLIENT_SECRET ? "✓ Set" : "✗ Missing"
15-
);
16-
console.log(" NEXTAUTH_URL:", process.env.NEXTAUTH_URL);
17-
console.log(
18-
" NEXTAUTH_SECRET:",
19-
process.env.NEXTAUTH_SECRET ? "✓ Set" : "✗ Missing"
20-
);
5+
// Allowed email domains for access control
6+
const ALLOWED_EMAIL_DOMAINS = [
7+
"pyrenees-atlantiques.gouv.fr",
8+
"seine-maritime.gouv.fr",
9+
"correze.gouv.fr",
10+
"dreets.gouv.fr",
11+
"travail.gouv.fr",
12+
"fabrique.social.gouv.fr",
13+
"sg.social.gouv.fr",
14+
// Add beta.gouv.fr for local development
15+
...(process.env.NODE_ENV === "development" ? ["beta.gouv.fr"] : []),
16+
];
17+
18+
// Helper function to check if email domain is allowed
19+
function isEmailDomainAllowed(email: string | null | undefined): boolean {
20+
if (!email) return false;
21+
22+
const emailDomain = email.split("@")[1]?.toLowerCase();
23+
if (!emailDomain) return false;
24+
25+
return ALLOWED_EMAIL_DOMAINS.some(
26+
(domain) => emailDomain === domain.toLowerCase()
27+
);
28+
}
2129

2230
export const authOptions: NextAuthOptions = {
2331
providers: [
@@ -27,62 +35,57 @@ export const authOptions: NextAuthOptions = {
2735
}),
2836
],
2937
callbacks: {
30-
async jwt({ token, account, profile }) {
31-
console.log("📝 JWT Callback:", {
32-
hasAccount: !!account,
33-
hasProfile: !!profile,
34-
});
35-
// Persist the OAuth access_token and profile to the token right after signin
38+
async signIn() {
39+
// Always return true to allow session creation with id_token
40+
// This is needed for proper ProConnect logout (requires id_token_hint)
41+
//
42+
// Security: Authorization is enforced in multiple layers:
43+
// 1. JWT callback marks unauthorized users (token.unauthorized = true)
44+
// 2. useAuth() returns isAuthenticated = false for unauthorized users
45+
// 3. AuthorizationCheck redirects unauthorized users to /access-denied
46+
//
47+
// This approach prevents the "user jail" problem while maintaining security
48+
return true;
49+
},
50+
async jwt({ token, account, profile, user }) {
51+
// Persist the OAuth tokens and profile after signin
3652
if (account) {
37-
console.log(" Account type:", account.provider);
3853
token.accessToken = account.access_token;
3954
token.idToken = account.id_token;
4055
}
4156
if (profile) {
42-
console.log(" Profile email:", profile.email);
4357
token.profile = profile as ProConnectProfile;
58+
59+
// Check if the user's email domain is allowed
60+
const email = profile.email || user?.email;
61+
if (!isEmailDomainAllowed(email)) {
62+
token.unauthorized = true;
63+
}
4464
}
4565
return token;
4666
},
4767
async session({ session, token }) {
48-
console.log("🔐 Session Callback:", {
49-
hasToken: !!token,
50-
hasUser: !!session.user,
51-
});
52-
// Send properties to the client
68+
// Pass tokens and profile to the client session
5369
session.accessToken = token.accessToken as string;
5470
session.idToken = token.idToken as string;
5571
session.profile = token.profile as ProConnectProfile | undefined;
72+
session.unauthorized = token.unauthorized as boolean | undefined;
5673
return session;
5774
},
5875
},
5976
pages: {
6077
signIn: "/",
61-
error: "/",
78+
error: "/access-denied",
6279
},
6380
events: {
6481
async signIn({ user }) {
65-
console.log("✅ Sign in event:", user.email);
6682
Sentry.setUser({
6783
id: user.id,
6884
email: user.email || undefined,
6985
});
7086
},
7187
async signOut() {
72-
console.log("👋 Sign out event");
7388
Sentry.setUser(null);
7489
},
7590
},
76-
logger: {
77-
error(code, metadata) {
78-
console.error("❌ NextAuth Error:", code, metadata);
79-
},
80-
warn(code) {
81-
console.warn("⚠️ NextAuth Warning:", code);
82-
},
83-
debug(code, metadata) {
84-
console.log("🐛 NextAuth Debug:", code, metadata);
85-
},
86-
},
87-
debug: true,
8891
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* ProConnect logout utilities
3+
* Handles proper logout from both NextAuth and ProConnect (OpenID Connect provider)
4+
*/
5+
6+
/**
7+
* Get the ProConnect end_session_endpoint URL based on environment
8+
*/
9+
export function getProConnectLogoutUrl(): string {
10+
const PROCONNECT_DOMAIN =
11+
process.env.NEXT_PUBLIC_PROCONNECT_ENV === "production"
12+
? "https://auth.agentconnect.gouv.fr/api/v2"
13+
: "https://fca.integ01.dev-agentconnect.fr/api/v2";
14+
15+
// OpenID Connect standard logout endpoint
16+
return `${PROCONNECT_DOMAIN}/session/end`;
17+
}
18+
19+
/**
20+
* Build the complete logout URL for ProConnect with required parameters
21+
* @param idToken - The ID token from the current session (optional - per OIDC spec)
22+
* @param postLogoutRedirectUri - Where to redirect after logout (must be registered in ProConnect)
23+
*/
24+
export function buildProConnectLogoutUrl(
25+
idToken: string,
26+
postLogoutRedirectUri: string
27+
): string {
28+
const logoutUrl = getProConnectLogoutUrl();
29+
const params = new URLSearchParams({
30+
post_logout_redirect_uri: postLogoutRedirectUri,
31+
});
32+
33+
// Only add id_token_hint if we have one (it's optional per OIDC spec)
34+
if (idToken) {
35+
params.set("id_token_hint", idToken);
36+
}
37+
38+
return `${logoutUrl}?${params.toString()}`;
39+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"use client";
2+
3+
import { Alert } from "@codegouvfr/react-dsfr/Alert";
4+
import { Button } from "@codegouvfr/react-dsfr/Button";
5+
import { signOut, useSession } from "next-auth/react";
6+
import { buildProConnectLogoutUrl } from "@/lib/auth/proconnect-logout";
7+
8+
export const AccessDeniedContent = () => {
9+
const { data: session } = useSession();
10+
11+
const handleSignOut = async () => {
12+
try {
13+
const idToken = session?.idToken;
14+
15+
if (idToken) {
16+
// Full ProConnect logout with id_token
17+
const postLogoutRedirectUri = window.location.origin;
18+
const proconnectLogoutUrl = buildProConnectLogoutUrl(
19+
idToken,
20+
postLogoutRedirectUri
21+
);
22+
23+
await signOut({ redirect: false });
24+
window.location.href = proconnectLogoutUrl;
25+
} else {
26+
// Fallback if no id_token
27+
await signOut({ callbackUrl: "/" });
28+
}
29+
} catch (error) {
30+
console.error("Error signing out:", error);
31+
await signOut({ callbackUrl: "/" });
32+
}
33+
};
34+
35+
return (
36+
<div className="fr-container fr-my-6w">
37+
<div className="fr-grid-row fr-grid-row--center">
38+
<div className="fr-col-12 fr-col-md-8 fr-col-lg-7">
39+
<Alert
40+
severity="error"
41+
title="Accès non autorisé"
42+
description={
43+
session?.user?.email
44+
? `L'adresse email ${session.user.email} n'est pas autorisée à accéder à cette application. Seuls les agents des domaines gouvernementaux autorisés peuvent se connecter.`
45+
: "Votre adresse email n'est pas autorisée à accéder à cette application. Seuls les agents des domaines gouvernementaux autorisés peuvent se connecter."
46+
}
47+
className="fr-mb-4w"
48+
/>
49+
50+
<div className="fr-callout fr-mb-4w">
51+
<h3 className="fr-callout__title">Domaines autorisés</h3>
52+
<ul>
53+
<li>pyrenees-atlantiques.gouv.fr</li>
54+
<li>seine-maritime.gouv.fr</li>
55+
<li>correze.gouv.fr</li>
56+
<li>dreets.gouv.fr</li>
57+
<li>travail.gouv.fr</li>
58+
<li>fabrique.social.gouv.fr</li>
59+
<li>sg.social.gouv.fr</li>
60+
</ul>
61+
</div>
62+
63+
<p className="fr-text--sm fr-mb-4w">
64+
Si vous pensez que votre domaine devrait être autorisé, veuillez
65+
contacter l&apos;administrateur de l&apos;application.
66+
</p>
67+
68+
<Button onClick={handleSignOut} priority="primary">
69+
Se déconnecter et retourner à l&apos;accueil
70+
</Button>
71+
</div>
72+
</div>
73+
</div>
74+
);
75+
};

0 commit comments

Comments
 (0)