-
Notifications
You must be signed in to change notification settings - Fork 1
Added Entra OIDC #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Shekar V (vshekar)
wants to merge
19
commits into
NSLS2:main
Choose a base branch
from
vshekar:add-entra
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
91ae2e5
Added Entra OIDC
vshekar 379968d
validateAndConsumeOidcFlow now clears cookie
vshekar 16ed7ba
Fix redirect uri-generation code
vshekar bea5d31
Removed getFreshEntraToken from imports
vshekar e7ecfe2
Add reauth when refresh token expired
vshekar ffc50df
Verify token store entry exists before authenticating client
vshekar 3f759fa
Send token to tiled-ws after checking both refresh and access cookie
vshekar e879b5e
Added stronger typing for cookie type
vshekar bdda426
Changed 'sameSite' from strict to lax in cookies
vshekar 946afcc
Added helper for auth-provider fallback
vshekar 344fb58
Fixed lint issues
vshekar fd141c4
Added dev/prod modes and set /auth/code as callback uri for entra
vshekar 4cd8417
Add assertAuthConfig to entra refresh route
vshekar 998cc70
Copilot suggestion for hardening for Next.js Edge runtime
vshekar 37f4754
Added sqlite database to track sessions and store tokens
vshekar 96f64d5
Added server only guards to auth files
vshekar a927603
Added copilot recommendations
vshekar 913c7de
Encrypt entra tokens in db
vshekar 8d411d3
More copilot fixes
vshekar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import { assertAuthConfig, buildCallbackUrl } from '@/lib/auth/config'; | ||
| import { validateAndConsumeOidcFlow, clearOidcFlowCookie } from '@/lib/auth/oidc-flow'; | ||
| import { exchangeCodeForUser } from '@/lib/auth/entra'; | ||
| import { setTokens } from '@/lib/auth/token-store'; | ||
| import { generateSessionId, issueSessionTokens } from '@/lib/auth/jwt'; | ||
| import { setSessionCookies } from '@/lib/auth/cookies'; | ||
|
|
||
| export const runtime = 'nodejs'; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| assertAuthConfig(); | ||
|
|
||
| const code = request.nextUrl.searchParams.get('code'); | ||
| const stateParam = request.nextUrl.searchParams.get('state'); | ||
| const error = request.nextUrl.searchParams.get('error'); | ||
|
|
||
| // Handle Entra error response | ||
| if (error) { | ||
| console.error('[Entra Callback] Error from Entra:', error); | ||
| const response = NextResponse.redirect(new URL('/login?error=signin_failed', request.url)); | ||
| clearOidcFlowCookie(response); | ||
| return response; | ||
| } | ||
|
|
||
| if (!code || !stateParam) { | ||
| const response = NextResponse.redirect(new URL('/login?error=missing_code', request.url)); | ||
| clearOidcFlowCookie(response); | ||
| return response; | ||
| } | ||
|
|
||
| const response = NextResponse.redirect(new URL('/', request.url)); | ||
|
|
||
| try { | ||
| // Validate OIDC flow state (checks cookie, state match) | ||
| const { nonce, codeVerifier } = await validateAndConsumeOidcFlow( | ||
| request, | ||
| response, | ||
| stateParam | ||
| ); | ||
|
|
||
| // Build the same redirect_uri used in the login request | ||
| const redirectUri = buildCallbackUrl(request); | ||
|
|
||
| // Exchange code for tokens and verify id_token | ||
| const { username, displayName, accessToken, refreshToken } = | ||
| await exchangeCodeForUser(code, redirectUri, codeVerifier, nonce); | ||
|
|
||
| const sessionId = generateSessionId(); | ||
|
|
||
| // Store Entra tokens for this app session | ||
| await setTokens(username, sessionId, { | ||
| entraAccessToken: accessToken, | ||
| entraRefreshToken: refreshToken, | ||
| storedAt: Date.now(), | ||
| }); | ||
|
|
||
| // Issue app-level session tokens | ||
| const sessionTokens = await issueSessionTokens(username, displayName, sessionId); | ||
|
|
||
| // Redirect to app root with session cookies set | ||
| setSessionCookies(response, sessionTokens.accessToken, sessionTokens.refreshToken); | ||
|
|
||
| return response; | ||
| } catch (err) { | ||
| console.error('[Entra Callback] Auth failed:', err instanceof Error ? err.message : err); | ||
| const errorResponse = NextResponse.redirect(new URL('/login?error=signin_failed', request.url)); | ||
| clearOidcFlowCookie(errorResponse); | ||
| return errorResponse; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import { | ||
| AUTH_ENDPOINT, | ||
| CLIENT_ID, | ||
| buildCallbackUrl, | ||
| entraUserScope, | ||
| assertAuthConfig, | ||
| } from '@/lib/auth/config'; | ||
| import { | ||
| createOidcFlowState, | ||
| buildPkceChallenge, | ||
| setOidcFlowCookie, | ||
| } from '@/lib/auth/oidc-flow'; | ||
|
|
||
| export const runtime = 'nodejs'; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| assertAuthConfig(); | ||
|
|
||
| // Generate OIDC flow state (state, nonce, PKCE code_verifier) | ||
| const flowState = createOidcFlowState(); | ||
| const codeChallenge = await buildPkceChallenge(flowState.codeVerifier); | ||
| const redirectUri = buildCallbackUrl(request); | ||
|
|
||
| // Build Entra authorize URL | ||
| const params = new URLSearchParams({ | ||
| client_id: CLIENT_ID, | ||
| response_type: 'code', | ||
| redirect_uri: redirectUri, | ||
| scope: entraUserScope(), | ||
| state: flowState.state, | ||
| nonce: flowState.nonce, | ||
| code_challenge: codeChallenge, | ||
| code_challenge_method: 'S256', | ||
| }); | ||
|
|
||
| const authorizeUrl = `${AUTH_ENDPOINT}?${params.toString()}`; | ||
|
|
||
| // Create redirect response and set OIDC flow cookie | ||
| const response = NextResponse.redirect(authorizeUrl); | ||
| await setOidcFlowCookie(response, flowState); | ||
|
|
||
| return response; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import { ACCESS_COOKIE, REFRESH_COOKIE } from '@/lib/auth/config'; | ||
| import { decodeSessionToken } from '@/lib/auth/jwt'; | ||
| import { clearSessionCookies } from '@/lib/auth/cookies'; | ||
| import { deleteTokens } from '@/lib/auth/token-store'; | ||
| import { clearOboCacheForSession } from '@/lib/auth/obo'; | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| let username: string | null = null; | ||
| let sessionId: string | null = null; | ||
|
|
||
| // Try to get username from access cookie first, then refresh cookie | ||
| const accessCookie = request.cookies.get(ACCESS_COOKIE)?.value; | ||
| if (accessCookie) { | ||
| try { | ||
| const payload = await decodeSessionToken(accessCookie, 'access'); | ||
| username = payload.sub; | ||
| sessionId = payload.sid || null; | ||
| } catch { | ||
| // Ignore -- try refresh cookie | ||
| } | ||
| } | ||
|
|
||
| if (!username) { | ||
| const refreshCookie = request.cookies.get(REFRESH_COOKIE)?.value; | ||
| if (refreshCookie) { | ||
| try { | ||
| const payload = await decodeSessionToken(refreshCookie, 'refresh'); | ||
| username = payload.sub; | ||
| sessionId = payload.sid || null; | ||
| } catch { | ||
| // Ignore | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Clean up server-side state | ||
| if (username && sessionId) { | ||
| await deleteTokens(username, sessionId); | ||
| clearOboCacheForSession(username, sessionId); | ||
| } | ||
|
|
||
| // Clear cookies | ||
| const response = NextResponse.json({ status: 'ok' }); | ||
| clearSessionCookies(response); | ||
|
|
||
| return response; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import { REFRESH_COOKIE, assertAuthConfig } from '@/lib/auth/config'; | ||
| import { decodeSessionToken, issueSessionTokens } from '@/lib/auth/jwt'; | ||
| import { setSessionCookies, clearSessionCookies } from '@/lib/auth/cookies'; | ||
| import { deleteTokens, getTokens, setTokens } from '@/lib/auth/token-store'; | ||
| import { isEntraTokenExpiring, refreshEntraAccessToken } from '@/lib/auth/entra'; | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| assertAuthConfig(); | ||
|
|
||
| const refreshCookie = request.cookies.get(REFRESH_COOKIE)?.value; | ||
|
|
||
| if (!refreshCookie) { | ||
| const response = NextResponse.json({ error: 'missing refresh token' }, { status: 401 }); | ||
| clearSessionCookies(response); | ||
| return response; | ||
| } | ||
|
Comment on lines
+11
to
+17
|
||
|
|
||
| let username: string; | ||
| let displayName: string; | ||
| let sessionId: string; | ||
|
|
||
| try { | ||
| const payload = await decodeSessionToken(refreshCookie, 'refresh'); | ||
| username = payload.sub; | ||
| displayName = payload.name || username; | ||
| sessionId = payload.sid || ''; | ||
|
|
||
| if (!sessionId) { | ||
| const response = NextResponse.json( | ||
| { error: 'invalid session token' }, | ||
| { status: 401 } | ||
| ); | ||
| clearSessionCookies(response); | ||
| return response; | ||
| } | ||
| } catch { | ||
| const response = NextResponse.json({ error: 'invalid refresh token' }, { status: 401 }); | ||
| clearSessionCookies(response); | ||
| return response; | ||
| } | ||
|
|
||
| // Try to refresh the underlying Entra token | ||
| const entry = await getTokens(username, sessionId); | ||
| if (!entry) { | ||
| const response = NextResponse.json({ error: 'missing entra credentials' }, { status: 401 }); | ||
| clearSessionCookies(response); | ||
| return response; | ||
| } | ||
|
|
||
| if (entry.entraRefreshToken) { | ||
| try { | ||
| const refreshed = await refreshEntraAccessToken(entry.entraRefreshToken); | ||
| await setTokens(username, sessionId, { | ||
| entraAccessToken: refreshed.accessToken, | ||
| entraRefreshToken: refreshed.refreshToken, | ||
| storedAt: Date.now(), | ||
| }); | ||
| } catch (err) { | ||
| console.error('[Entra Refresh] Failed:', err instanceof Error ? err.message : err); | ||
| // Clear stored tokens on refresh failure | ||
| await deleteTokens(username, sessionId); | ||
| const response = NextResponse.json( | ||
| { error: 'entra refresh failed' }, | ||
| { status: 401 } | ||
| ); | ||
| clearSessionCookies(response); | ||
| return response; | ||
| } | ||
| } else if (isEntraTokenExpiring(entry.entraAccessToken)) { | ||
| // No refresh token available and the access token is expiring/expired. | ||
| await deleteTokens(username, sessionId); | ||
| const response = NextResponse.json( | ||
| { error: 'entra refresh token missing; re-authentication required' }, | ||
| { status: 401 } | ||
| ); | ||
| clearSessionCookies(response); | ||
| return response; | ||
| } | ||
|
|
||
| // Issue new session tokens | ||
| const newTokens = await issueSessionTokens(username, displayName, sessionId); | ||
| const response = NextResponse.json({ status: 'ok' }); | ||
| setSessionCookies(response, newTokens.accessToken, newTokens.refreshToken); | ||
|
|
||
| return response; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import { getSessionFromRequest, setSessionCookies, clearSessionCookies } from '@/lib/auth/cookies'; | ||
| import { issueSessionTokens } from '@/lib/auth/jwt'; | ||
| import { deleteTokens, getTokens } from '@/lib/auth/token-store'; | ||
| import { isEntraTokenExpiring } from '@/lib/auth/entra'; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| const session = await getSessionFromRequest(request); | ||
|
|
||
| if (!session) { | ||
| return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); | ||
| } | ||
|
|
||
| if (!session.sessionId) { | ||
| const response = NextResponse.json({ error: 'not authenticated' }, { status: 401 }); | ||
| clearSessionCookies(response); | ||
| return response; | ||
| } | ||
|
|
||
| // Ensure server-side Entra credentials still exist | ||
| const entry = await getTokens(session.username, session.sessionId); | ||
| if (!entry) { | ||
| const response = NextResponse.json({ error: 'not authenticated' }, { status: 401 }); | ||
| clearSessionCookies(response); | ||
| return response; | ||
| } | ||
|
|
||
| // If Entra access is expiring and cannot be refreshed, force re-authentication | ||
| if (!entry.entraRefreshToken && isEntraTokenExpiring(entry.entraAccessToken)) { | ||
| await deleteTokens(session.username, session.sessionId); | ||
| const response = NextResponse.json({ error: 'not authenticated' }, { status: 401 }); | ||
| clearSessionCookies(response); | ||
| return response; | ||
| } | ||
|
|
||
| // If the access token was valid, return user info directly | ||
| if (session.source === 'access') { | ||
| return NextResponse.json({ | ||
| username: session.username, | ||
| display_name: session.displayName, | ||
| source: 'entra', | ||
| }); | ||
| } | ||
|
|
||
| // Access expired but refresh is valid -- reissue session tokens | ||
| const newTokens = await issueSessionTokens( | ||
| session.username, | ||
| session.displayName, | ||
| session.sessionId | ||
| ); | ||
| const response = NextResponse.json({ | ||
| username: session.username, | ||
| display_name: session.displayName, | ||
| source: 'entra', | ||
| }); | ||
| setSessionCookies(response, newTokens.accessToken, newTokens.refreshToken); | ||
|
|
||
| return response; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.