Added Entra OIDC#3
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds Microsoft Entra ID (OIDC + PKCE) sign-in to the Next.js app and bridges Entra-authenticated sessions to Tiled by exchanging Entra access tokens for Tiled-scoped tokens via the OAuth On-Behalf-Of (OBO) flow. It updates the client auth model to support both legacy Tiled token/API-key auth and Entra cookie-based sessions.
Changes:
- Add Entra OIDC login/callback/logout + cookie-backed session endpoints, backed by
jose-signed session JWTs. - Add server-side Entra token + OBO token in-memory stores to mint Tiled auth for the REST/WS proxies.
- Update client auth provider/UI and proxy callers to use cookie-based auth in Entra mode (no client-side Authorization header / no WS query token).
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Adds jose dependency required for JWT/JWKS operations. |
| package-lock.json | Locks jose to the new version and updates dependency metadata. |
| lib/tiled/websocket.ts | Stops sending tokens via WS query params for Entra sessions (cookie-based auth instead). |
| lib/tiled/types.ts | Introduces AppSessionUser to unify identity across Entra + Tiled auth modes. |
| lib/tiled/client.ts | Skips Authorization header in Entra mode and triggers server refresh on 401s. |
| lib/tiled/auth.ts | Adds entra auth marker helpers and prevents token access in Entra mode. |
| lib/auth/token-store.ts | Adds in-memory Entra credential store + “fresh token” helper. |
| lib/auth/oidc-flow.ts | Implements OIDC state/nonce/PKCE handling via an httpOnly cookie JWT. |
| lib/auth/obo.ts | Implements OBO token exchange and caches Tiled-scoped tokens per user. |
| lib/auth/jwt.ts | Implements app session JWT issuance/verification for access/refresh cookies. |
| lib/auth/entra.ts | Implements Entra code exchange, id_token verification via JWKS, and refresh. |
| lib/auth/cookies.ts | Adds helpers to set/clear/read app session cookies. |
| lib/auth/config.ts | Adds auth env/config validation and callback URL derivation. |
| components/providers/auth-provider.tsx | Adds Entra login flow + refresh loop; migrates user to AppSessionUser. |
| components/layout/header.tsx | Displays unified user display name and an SSO badge for Entra sessions. |
| app/api/ws/[...path]/route.ts | WS proxy now supports cookie-based auth and OBO exchange for Entra sessions. |
| app/api/tiled/[...path]/route.ts | REST proxy supports cookie-based auth and OBO exchange for Entra sessions. |
| app/api/auth/session/route.ts | Adds session introspection endpoint used by the client to detect Entra sessions. |
| app/api/auth/entra/refresh/route.ts | Adds server-side session refresh endpoint for cookie sessions. |
| app/api/auth/entra/logout/route.ts | Adds logout endpoint to clear cookies and server-side token caches. |
| app/api/auth/entra/login/route.ts | Adds Entra login endpoint (redirect + PKCE setup). |
| app/api/auth/entra/callback/route.ts | Adds callback handler exchanging code for tokens and setting session cookies. |
| app/(auth)/login/page.tsx | Adds “Sign in with Microsoft” button to initiate Entra login. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (3)
app/api/tiled/[...path]/route.ts:1
- This proxy only attempts cookie-session auth via the access cookie. If the access cookie is expired but a refresh cookie is still valid (a case you already handle elsewhere via
getSessionFromRequest), this route will fall back toAuthorization(which Entra-mode clients won’t set) and can intermittently 401. A concrete fix is to also attempt refresh-cookie decoding (or reusegetSessionFromRequestand, whensource === 'refresh', re-issue cookies) before falling back to the Authorization header.
components/providers/auth-provider.tsx:255 isAuthenticatedis set totrueeven whentiledUserisnull(e.g., if/api/auth/whoamifails temporarily). That leaves the app in an inconsistent state (authenticated but no user identity). SetisAuthenticatedbased on!!tiledUser(or otherwise ensure a non-null user object is always available after successful password login).
setState({
user: tiledUser ? { username: tiledUser.identities?.[0]?.id || username, displayName: tiledUser.identities?.[0]?.id || username, source: 'tiled', tiledUser } : null,
isAuthenticated: true,
isLoading: false,
accessToken: tokens.access_token,
});
app/api/ws/[...path]/route.ts:1
- Manual cookie parsing via regex is brittle (encoding quirks, unusual cookie values, multiple cookies, etc.). Since this handler already has a request object, prefer a cookie parser utility (or
request.cookies.get(...)if available in this context) to reliably read cookie values without regex edge cases.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (3)
app/api/tiled/[...path]/route.ts:1
- This only attempts cookie-session auth with the access cookie. If the access JWT is expired but the refresh JWT is still valid (common), Entra-mode requests will fall back to the Authorization header (which Entra clients intentionally omit) and fail until the client refreshes. Consider also attempting to authenticate via the refresh cookie (similar to the WS proxy), or reissuing via
/api/auth/session/refresh before falling back to header auth.
app/api/ws/[...path]/route.ts:1 - If
getOboTokenForUser(...)rejects insidecookieAuthPromise(e.g., DB unavailable, refresh failure),await cookieAuthPromisewill throw and can abort the WS connection flow even when a query-string token was provided as a fallback. Wrap this await in a try/catch (or ensure the promise always resolves to null on any error) so the proxy can gracefully fall back totoken/auth_typequery params.
app/api/ws/[...path]/route.ts:1 - Parsing cookies via regex is brittle (quoting, duplicate keys, unusual whitespace, and cookie-name regex escaping). Prefer using a standard cookie parser (e.g., the
cookiepackage) to robustly parserequest.headers.cookie, and escape cookie names if you keep regex. This will reduce authentication edge cases on the WS handshake.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (4)
app/api/ws/[...path]/route.ts:1
- The manual cookie parsing/decoding here is fragile: cookie values may be quoted, may include unexpected characters, and
decodeURIComponent(...)can throw (which would fail WS auth unexpectedly). Consider using a standard cookie parser (e.g.,cookie.parse) and avoid unconditional URI decoding; if decoding is needed, wrap it in a try/catch and strip surrounding quotes.
app/api/ws/[...path]/route.ts:1 - The manual cookie parsing/decoding here is fragile: cookie values may be quoted, may include unexpected characters, and
decodeURIComponent(...)can throw (which would fail WS auth unexpectedly). Consider using a standard cookie parser (e.g.,cookie.parse) and avoid unconditional URI decoding; if decoding is needed, wrap it in a try/catch and strip surrounding quotes.
app/api/ws/[...path]/route.ts:1 - The manual cookie parsing/decoding here is fragile: cookie values may be quoted, may include unexpected characters, and
decodeURIComponent(...)can throw (which would fail WS auth unexpectedly). Consider using a standard cookie parser (e.g.,cookie.parse) and avoid unconditional URI decoding; if decoding is needed, wrap it in a try/catch and strip surrounding quotes.
app/api/tiled/[...path]/route.ts:1 - This proxy only attempts cookie-session auth using the access cookie; if the access cookie is expired but a valid refresh cookie exists, the request falls back to
Authorization(which Entra sessions typically won’t send), causing an avoidable 401/extra retry loop. To make Entra sessions more resilient (and consistent with the WS proxy), consider usinggetSessionFromRequest(request)here (or explicitly checking the refresh cookie) so refresh-based sessions can still obtain an OBO token.
| const exists = await db.schema.hasTable(TABLE_NAME); | ||
| if (!exists) { | ||
| await db.schema.createTable(TABLE_NAME, (table) => { | ||
| table.string('username').notNullable(); | ||
| table.string('session_id').notNullable(); | ||
| table.text('entra_access_token').notNullable(); | ||
| table.text('entra_refresh_token').nullable(); | ||
| table.bigInteger('stored_at').notNullable(); | ||
| table.bigInteger('updated_at').notNullable(); | ||
| table.bigInteger('last_used_at').notNullable(); | ||
| table.primary(['username', 'session_id']); | ||
| table.index(['updated_at']); | ||
| table.index(['last_used_at']); | ||
| }); | ||
| } |
| const refreshCookie = request.cookies.get(REFRESH_COOKIE)?.value; | ||
|
|
||
| if (!refreshCookie) { | ||
| return NextResponse.json({ error: 'missing refresh token' }, { status: 401 }); | ||
| } |
| function getDatabaseUrl() { | ||
| return (process.env.DATABASE_URL || '').trim() || 'file:./data/app.sqlite'; | ||
| } | ||
|
|
||
| function normalizeSqlitePath(rawPath) { | ||
| if (rawPath.startsWith('//')) { | ||
| return path.normalize(rawPath); | ||
| } | ||
| if (path.isAbsolute(rawPath)) { | ||
| return rawPath; | ||
| } | ||
| return path.resolve(process.cwd(), rawPath); | ||
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 29 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
app/api/tiled/[...path]/route.ts:1
- This route only attempts cookie-session auth using the access cookie. If the access cookie is expired but the refresh cookie is still valid (which is expected in this design), Entra users will fall through to header-based auth (likely missing) and get 401s until the client refresh loop runs. Consider mirroring the WS proxy behavior by also checking the refresh cookie, or reusing
getSessionFromRequest()and (whensource === 'refresh') issuing new session cookies on this response before callinggetOboTokenForUser(...).
| import fs from 'node:fs'; | ||
| import knex from 'knex'; | ||
| import { getDatabaseUrl, normalizeSqlitePath } from '../lib/db/url-utils.mjs'; | ||
|
|
||
| function createSqliteClient(databaseUrl) { | ||
| const raw = databaseUrl.slice('file:'.length); | ||
| const filename = normalizeSqlitePath(raw || './data/app.sqlite'); | ||
|
|
||
| fs.mkdirSync(path.dirname(filename), { recursive: true }); |
| await db(TABLE_NAME) | ||
| .insert({ | ||
| username, | ||
| session_id: sessionId, | ||
| entra_access_token: entry.entraAccessToken, | ||
| entra_refresh_token: entry.entraRefreshToken, | ||
| stored_at: entry.storedAt, | ||
| updated_at: now, | ||
| last_used_at: now, | ||
| }) |
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import knex, { Knex } from 'knex'; | ||
| import { getDatabaseUrl, normalizeSqlitePath } from './url-utils.mjs'; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 31 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (3)
app/api/tiled/[...path]/route.ts:1
- The REST proxy only attempts cookie-based auth using the access cookie. If the access cookie is expired but the refresh cookie is still valid, Entra sessions will unnecessarily fall back to
Authorizationand likely fail. Align this with the WS proxy logic by also attempting to decode/use the refresh cookie (or otherwise derivesub/sid) when the access cookie is missing/invalid.
scripts/db-migrate.mjs:1 - The
entra_credentialsschema is duplicated here and inlib/auth/token-store.ts(ensureSchema). This creates a drift risk (e.g., future column/index changes applied in only one location). Consider extracting the table-definition logic into a shared module (or relying on a single migration path) so both runtime bootstrap anddb:migratestay consistent.
app/api/ws/[...path]/route.ts:1 - This changes the exported
SOCKETsignature by dropping theserverparameter. Even if unused, keeping the full(client, request, server)signature helps maintain compatibility with frameworks/adapters that expect that callback shape (and avoids potential type/interface mismatches). Consider reintroducing the third parameter as an unused argument.
| let key: Buffer; | ||
| try { | ||
| key = Buffer.from(ENTRA_TOKEN_ENCRYPTION_KEY, 'base64'); | ||
| } catch { | ||
| throw new Error('[auth/token-crypto] ENTRA_TOKEN_ENCRYPTION_KEY must be valid base64'); | ||
| } |
| function getOboCache(): Map<string, OboTokenEntry> { | ||
| const g = globalThis as Record<string, unknown>; | ||
| if (!g[oboCacheKey]) { | ||
| g[oboCacheKey] = new Map<string, OboTokenEntry>(); | ||
| } | ||
| return g[oboCacheKey] as Map<string, OboTokenEntry>; | ||
| } |
| try { | ||
| const sessionResponse = await fetch('/api/auth/session'); | ||
| if (sessionResponse.ok) { | ||
| const sessionData = await sessionResponse.json(); | ||
| setEntraAuthMarker(); | ||
| setState({ | ||
| user: { | ||
| username: sessionData.username, | ||
| displayName: sessionData.display_name, | ||
| source: 'entra', | ||
| }, | ||
| isAuthenticated: true, | ||
| isLoading: false, | ||
| accessToken: null, // Server-side cookies handle auth | ||
| }); | ||
| return; | ||
| } | ||
| // Non-200: clear stale Entra marker | ||
| clearEntraAuthMarker(); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 31 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
app/api/tiled/[...path]/route.ts:1
- This proxy only attempts cookie-based auth using the access cookie. When the access cookie expires (but the refresh cookie is still valid), Entra sessions will incorrectly fall back to
Authorizationheader (which the browser won’t provide) and fail. Align with the WS proxy/session logic by also accepting the refresh cookie (decode asrefresh) to obtainsub/sidforgetOboTokenForUser, and optionally reissue fresh session cookies on the response.
| const refreshEntra = async () => { | ||
| try { | ||
| const response = await fetch('/api/auth/entra/refresh', { method: 'POST' }); | ||
| if (!response.ok) { | ||
| console.log('[Auth] Entra refresh failed, re-checking session...'); | ||
| checkAuth(); | ||
| } | ||
| } catch { | ||
| // Network error, skip | ||
| } | ||
| }; |
| async function runCleanupIfNeeded(): Promise<void> { | ||
| const g = globalThis as Record<string, unknown>; | ||
| const now = Date.now(); | ||
| const lastRun = (g[lastCleanupKey] as number | undefined) ?? 0; | ||
| if (now - lastRun < cleanupIntervalMs) { | ||
| return; | ||
| } | ||
|
|
||
| g[lastCleanupKey] = now; | ||
| const db = getDbClient(); |
| // In-process OBO token cache (keyed by `${username}:${scope}`) | ||
| // --------------------------------------------------------------------------- |
No description provided.