diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..6da992a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
+# API backend — URL relative : Next.js proxy vers nginx (:80), pas de CORS en dev
+NEXT_PUBLIC_API_URL=/api/v1
+NEXT_PUBLIC_WS_URL=ws://localhost/ws
+# Cible du proxy Next (optionnel, défaut 127.0.0.1:80)
+# ULTI_PROXY_ORIGIN=http://127.0.0.1
+
+# OIDC Authentik (blueprints deploy/authentik dans ulti-backend)
+NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/
+NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
+# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
+NEXT_PUBLIC_APP_URL=http://localhost:3000
+
+# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
+OIDC_CLIENT_SECRET=changeme
diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts
new file mode 100644
index 0000000..4c6b9e9
--- /dev/null
+++ b/app/api/auth/callback/route.ts
@@ -0,0 +1,128 @@
+import { cookies } from "next/headers"
+import { NextResponse } from "next/server"
+import { resolveServerOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config"
+import { platformUserFromToken } from "@/lib/auth/jwt-claims"
+import {
+ applySessionCookies,
+ type TokenResponse,
+} from "@/lib/auth/session"
+
+const PKCE_COOKIE = "ulti_pkce_verifier"
+const STATE_COOKIE = "ulti_oauth_state"
+const INTENT_COOKIE = "ulti_auth_intent"
+const PREVIOUS_SUB_COOKIE = "ulti_auth_previous_sub"
+
+export async function GET(request: Request) {
+ const url = new URL(request.url)
+ const appOrigin = getAppOrigin()
+ const code = url.searchParams.get("code")
+ const state = url.searchParams.get("state")
+ const oauthError = url.searchParams.get("error")
+
+ if (oauthError) {
+ const desc = url.searchParams.get("error_description") ?? oauthError
+ return NextResponse.redirect(
+ new URL(`/login?error=${encodeURIComponent(desc)}`, appOrigin)
+ )
+ }
+
+ if (!code || !state) {
+ return NextResponse.redirect(
+ new URL("/login?error=missing_code", appOrigin)
+ )
+ }
+
+ const jar = await cookies()
+ const expectedState = jar.get(STATE_COOKIE)?.value
+ const verifier = jar.get(PKCE_COOKIE)?.value
+ const returnTo = jar.get("ulti_auth_return")?.value ?? "/mail/inbox"
+ const authIntent = jar.get(INTENT_COOKIE)?.value
+ const previousSub = jar.get(PREVIOUS_SUB_COOKIE)?.value
+
+ if (!expectedState || state !== expectedState || !verifier) {
+ return NextResponse.redirect(
+ new URL(
+ `/login?error=${encodeURIComponent(
+ !expectedState || !verifier
+ ? "invalid_state:missing_oauth_cookies"
+ : "invalid_state:state_mismatch"
+ )}`,
+ appOrigin
+ )
+ )
+ }
+
+ let cfg
+ try {
+ cfg = await resolveServerOidcConfig()
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : "oidc_discovery_failed"
+ return NextResponse.redirect(
+ new URL(`/login?error=${encodeURIComponent(message)}`, appOrigin)
+ )
+ }
+ const body = new URLSearchParams({
+ grant_type: "authorization_code",
+ client_id: cfg.clientId,
+ client_secret: cfg.clientSecret,
+ code,
+ redirect_uri: cfg.redirectUri,
+ code_verifier: verifier,
+ })
+
+ let tokens: TokenResponse
+ try {
+ const res = await fetch(cfg.tokenEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body,
+ })
+ if (!res.ok) {
+ const text = await res.text()
+ return NextResponse.redirect(
+ new URL(
+ `/login?error=${encodeURIComponent(`token_exchange_failed:${text.slice(0, 120)}`)}`,
+ appOrigin
+ )
+ )
+ }
+ tokens = (await res.json()) as TokenResponse
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "token_exchange_failed"
+ return NextResponse.redirect(
+ new URL(`/login?error=${encodeURIComponent(message)}`, appOrigin)
+ )
+ }
+
+ if (!tokens.id_token) {
+ return NextResponse.redirect(
+ new URL("/login?error=no_id_token", appOrigin)
+ )
+ }
+
+ const bearer = tokens.id_token
+
+ const newUser = platformUserFromToken(bearer)
+ const completeUrl = new URL("/auth/complete", appOrigin)
+ completeUrl.searchParams.set("returnTo", returnTo)
+ if (
+ authIntent === "add_account" &&
+ previousSub &&
+ newUser?.sub === previousSub
+ ) {
+ completeUrl.searchParams.set("accountNotice", "same")
+ }
+
+ const response = NextResponse.redirect(completeUrl)
+
+ response.cookies.delete(PKCE_COOKIE)
+ response.cookies.delete(STATE_COOKIE)
+ response.cookies.delete("ulti_auth_return")
+ response.cookies.delete(INTENT_COOKIE)
+ response.cookies.delete(PREVIOUS_SUB_COOKIE)
+
+ applySessionCookies(response, tokens, bearer)
+
+ return response
+}
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 0000000..b81293f
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,77 @@
+import { cookies } from "next/headers"
+import { NextResponse } from "next/server"
+import { createPkcePair, randomString } from "@/lib/auth/pkce"
+import { platformUserFromToken } from "@/lib/auth/jwt-claims"
+import { resolveOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config"
+
+const PKCE_COOKIE = "ulti_pkce_verifier"
+const STATE_COOKIE = "ulti_oauth_state"
+const INTENT_COOKIE = "ulti_auth_intent"
+const PREVIOUS_SUB_COOKIE = "ulti_auth_previous_sub"
+const COOKIE_MAX_AGE = 600
+
+function oauthCookieOptions() {
+ return {
+ httpOnly: true,
+ sameSite: "lax" as const,
+ path: "/",
+ maxAge: COOKIE_MAX_AGE,
+ secure: process.env.NODE_ENV === "production",
+ }
+}
+
+export async function GET(request: Request) {
+ let cfg
+ try {
+ cfg = await resolveOidcConfig()
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : "oidc_discovery_failed"
+ return NextResponse.redirect(
+ new URL(
+ `/login?error=${encodeURIComponent(message)}`,
+ getAppOrigin()
+ )
+ )
+ }
+ const { verifier, challenge } = await createPkcePair()
+ const state = randomString(16)
+ const requestUrl = new URL(request.url)
+ const returnTo = requestUrl.searchParams.get("returnTo") ?? "/mail/inbox"
+ const intent = requestUrl.searchParams.get("intent")
+ const prompt =
+ requestUrl.searchParams.get("prompt") ??
+ (intent === "add_account" ? "login select_account" : "select_account")
+
+ const jar = await cookies()
+ const existingUser = platformUserFromToken(
+ jar.get("ulti_access_token")?.value ?? ""
+ )
+
+ const params = new URLSearchParams({
+ client_id: cfg.clientId,
+ redirect_uri: cfg.redirectUri,
+ response_type: "code",
+ scope: "openid profile email offline_access",
+ state,
+ code_challenge: challenge,
+ code_challenge_method: "S256",
+ prompt,
+ })
+
+ const response = NextResponse.redirect(
+ `${cfg.authorizationEndpoint}?${params.toString()}`
+ )
+ const cookieOpts = oauthCookieOptions()
+ response.cookies.set(PKCE_COOKIE, verifier, cookieOpts)
+ response.cookies.set(STATE_COOKIE, state, cookieOpts)
+ response.cookies.set("ulti_auth_return", returnTo, cookieOpts)
+ if (intent === "add_account") {
+ response.cookies.set(INTENT_COOKIE, "add_account", cookieOpts)
+ if (existingUser?.sub) {
+ response.cookies.set(PREVIOUS_SUB_COOKIE, existingUser.sub, cookieOpts)
+ }
+ }
+
+ return response
+}
diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..4488333
--- /dev/null
+++ b/app/api/auth/logout/route.ts
@@ -0,0 +1,22 @@
+import { cookies } from "next/headers"
+import { NextResponse } from "next/server"
+
+const SESSION_COOKIES = [
+ "ulti_session",
+ "ulti_access_token",
+ "ulti_refresh_token",
+ "ulti_expires_at",
+ "ulti_pkce_verifier",
+ "ulti_oauth_state",
+ "ulti_auth_return",
+ "ulti_auth_intent",
+ "ulti_auth_previous_sub",
+] as const
+
+export async function POST() {
+ const response = NextResponse.json({ ok: true })
+ for (const name of SESSION_COOKIES) {
+ response.cookies.delete(name)
+ }
+ return response
+}
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
new file mode 100644
index 0000000..309fd1f
--- /dev/null
+++ b/app/api/auth/session/route.ts
@@ -0,0 +1,60 @@
+import { cookies } from "next/headers"
+import { NextResponse } from "next/server"
+import { platformUserFromToken } from "@/lib/auth/jwt-claims"
+import { resolveServerOidcConfig } from "@/lib/auth/oidc-config"
+import {
+ SESSION_COOKIE_NAMES,
+ applySessionCookies,
+ computeExpiresAt,
+ exchangeRefreshToken,
+ isAccessTokenValid,
+ resolveBearerToken,
+} from "@/lib/auth/session"
+
+export async function GET() {
+ const jar = await cookies()
+ const accessToken = jar.get(SESSION_COOKIE_NAMES.accessToken)?.value
+ const refreshToken = jar.get(SESSION_COOKIE_NAMES.refreshToken)?.value
+ const expiresAtRaw = jar.get(SESSION_COOKIE_NAMES.expiresAt)?.value
+
+ if (!accessToken && !refreshToken) {
+ return NextResponse.json({ authenticated: false })
+ }
+
+ if (isAccessTokenValid(accessToken, expiresAtRaw)) {
+ const expiresAt = Number(expiresAtRaw)
+ const user = platformUserFromToken(accessToken!)
+ return NextResponse.json({
+ authenticated: true,
+ accessToken,
+ refreshToken: refreshToken ?? null,
+ expiresAt,
+ user,
+ })
+ }
+
+ if (!refreshToken) {
+ return NextResponse.json({ authenticated: false, expired: true })
+ }
+
+ try {
+ const cfg = await resolveServerOidcConfig()
+ const tokens = await exchangeRefreshToken(refreshToken, cfg)
+ const bearer = resolveBearerToken(tokens)
+ const expiresAt = computeExpiresAt(tokens.expires_in ?? 3600)
+ const user = platformUserFromToken(bearer)
+
+ const response = NextResponse.json({
+ authenticated: true,
+ accessToken: bearer,
+ refreshToken: tokens.refresh_token ?? refreshToken,
+ expiresAt,
+ user,
+ refreshed: true,
+ })
+ applySessionCookies(response, tokens, bearer)
+ return response
+ } catch {
+ return NextResponse.json({ authenticated: false, expired: true })
+ }
+}
diff --git a/app/auth/complete/page.tsx b/app/auth/complete/page.tsx
new file mode 100644
index 0000000..5a15fae
--- /dev/null
+++ b/app/auth/complete/page.tsx
@@ -0,0 +1,69 @@
+"use client"
+
+import { useEffect, Suspense } from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { useAuthStore } from "@/lib/api/auth-store"
+import type { PlatformUser } from "@/lib/auth/jwt-claims"
+
+function AuthCompleteInner() {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const login = useAuthStore((s) => s.login)
+ const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
+ const accountNotice = searchParams.get("accountNotice")
+
+ useEffect(() => {
+ let cancelled = false
+
+ async function finish() {
+ try {
+ const res = await fetch("/api/auth/session", { credentials: "include" })
+ const data = (await res.json()) as {
+ authenticated?: boolean
+ accessToken?: string
+ refreshToken?: string | null
+ expiresAt?: number
+ user?: PlatformUser | null
+ }
+ if (
+ data.authenticated &&
+ data.accessToken &&
+ data.expiresAt &&
+ !cancelled
+ ) {
+ login(
+ data.accessToken,
+ data.refreshToken ?? "",
+ data.expiresAt,
+ data.user ?? null
+ )
+ if (accountNotice === "same") {
+ sessionStorage.setItem("ulti_account_notice", "same")
+ }
+ router.replace(returnTo.startsWith("/") ? returnTo : "/mail/inbox")
+ return
+ }
+ } catch {
+ // fall through
+ }
+ if (!cancelled) {
+ router.replace("/login?error=session_failed")
+ }
+ }
+
+ void finish()
+ return () => {
+ cancelled = true
+ }
+ }, [accountNotice, login, returnTo, router])
+
+ return null
+}
+
+export default function AuthCompletePage() {
+ return (
+