Skip to content

Added Entra OIDC#3

Open
Shekar V (vshekar) wants to merge 19 commits into
NSLS2:mainfrom
vshekar:add-entra
Open

Added Entra OIDC#3
Shekar V (vshekar) wants to merge 19 commits into
NSLS2:mainfrom
vshekar:add-entra

Conversation

@vshekar

Copy link
Copy Markdown

No description provided.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/auth/oidc-flow.ts
Comment thread lib/auth/config.ts Outdated
Comment thread app/api/auth/entra/refresh/route.ts Outdated
Comment thread app/api/auth/entra/refresh/route.ts
Comment thread app/api/auth/session/route.ts
Comment thread app/api/ws/[...path]/route.ts
Comment thread app/api/ws/[...path]/route.ts Outdated
Comment thread lib/auth/jwt.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to Authorization (which Entra-mode clients won’t set) and can intermittently 401. A concrete fix is to also attempt refresh-cookie decoding (or reuse getSessionFromRequest and, when source === 'refresh', re-issue cookies) before falling back to the Authorization header.
    components/providers/auth-provider.tsx:255
  • isAuthenticated is set to true even when tiledUser is null (e.g., if /api/auth/whoami fails temporarily). That leaves the app in an inconsistent state (authenticated but no user identity). Set isAuthenticated based 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.

Comment thread lib/auth/cookies.ts
Comment thread components/providers/auth-provider.tsx Outdated
Comment thread lib/auth/token-store.ts Outdated
Comment thread lib/auth/config.ts Outdated
Comment thread app/api/auth/entra/refresh/route.ts
Comment thread lib/auth/oidc-flow.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 inside cookieAuthPromise (e.g., DB unavailable, refresh failure), await cookieAuthPromise will 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 to token/auth_type query 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 cookie package) to robustly parse request.headers.cookie, and escape cookie names if you keep regex. This will reduce authentication edge cases on the WS handshake.

Comment thread lib/auth/token-store.ts
Comment thread lib/auth/token-store.ts
Comment thread lib/auth/config.ts
Comment thread lib/auth/config.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 using getSessionFromRequest(request) here (or explicitly checking the refresh cookie) so refresh-based sessions can still obtain an OBO token.

Comment thread lib/auth/token-store.ts
Comment on lines +39 to +53
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']);
});
}
Comment on lines +11 to +15
const refreshCookie = request.cookies.get(REFRESH_COOKIE)?.value;

if (!refreshCookie) {
return NextResponse.json({ error: 'missing refresh token' }, { status: 401 });
}
Comment thread scripts/db-migrate.mjs Outdated
Comment on lines +5 to +17
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);
}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (when source === 'refresh') issuing new session cookies on this response before calling getOboTokenForUser(...).

Comment thread scripts/db-migrate.mjs
Comment on lines +1 to +9
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 });
Comment thread lib/auth/token-store.ts
Comment on lines +163 to +172
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,
})
Comment thread lib/db/client.ts Outdated
import fs from 'node:fs';
import path from 'node:path';
import knex, { Knex } from 'knex';
import { getDatabaseUrl, normalizeSqlitePath } from './url-utils.mjs';

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Authorization and likely fail. Align this with the WS proxy logic by also attempting to decode/use the refresh cookie (or otherwise derive sub/sid) when the access cookie is missing/invalid.
    scripts/db-migrate.mjs:1
  • The entra_credentials schema is duplicated here and in lib/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 and db:migrate stay consistent.
    app/api/ws/[...path]/route.ts:1
  • This changes the exported SOCKET signature by dropping the server parameter. 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.

Comment thread lib/auth/token-crypto.ts Outdated
Comment on lines +17 to +22
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');
}
Comment thread lib/auth/obo.ts
Comment on lines +21 to +27
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>;
}
Comment thread components/providers/auth-provider.tsx Outdated
Comment on lines +64 to +82
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();

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Authorization header (which the browser won’t provide) and fail. Align with the WS proxy/session logic by also accepting the refresh cookie (decode as refresh) to obtain sub/sid for getOboTokenForUser, and optionally reissue fresh session cookies on the response.

Comment on lines +189 to +199
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
}
};
Comment thread lib/auth/token-store.ts
Comment on lines +88 to +97
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();
Comment thread lib/auth/obo.ts
Comment on lines +16 to +17
// In-process OBO token cache (keyed by `${username}:${scope}`)
// ---------------------------------------------------------------------------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants