Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,32 @@ npm run build
npm start
```

## Database Setup

The app uses a single `DATABASE_URL` for persistence. Supported values:

- `file:./data/app.sqlite` (default fallback)
- `postgres://...` or `postgresql://...`

Security note:

- Persisted Entra credentials are encrypted at the application layer.
- You must provide `ENTRA_TOKEN_ENCRYPTION_KEY` (base64-encoded 32 bytes).
- Generate with: `openssl rand -base64 32`
- Keep this key stable across restarts/deploys, or stored credentials become unreadable.

Initialize schema during deploy:

```bash
npm run db:migrate
```

Notes:

- For SQLite, the DB file and parent directory are created automatically if missing.
- The app also bootstraps the `entra_credentials` table at runtime if not present.
- Running `npm run db:migrate` is still recommended in deploy automation for fail-fast startup.

## WebSocket Events

The dashboard listens for `container-child-created` events:
Expand Down
25 changes: 24 additions & 1 deletion app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type AuthMode = 'password' | 'apikey';

export default function LoginPage() {
const router = useRouter();
const { login, loginWithApiKey, isLoading } = useAuth();
const { login, loginWithApiKey, loginWithEntra, isLoading } = useAuth();
const [mode, setMode] = useState<AuthMode>('password');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
Expand Down Expand Up @@ -79,6 +79,29 @@ export default function LoginPage() {
</p>
</div>

{/* Microsoft SSO */}
<Button
type="button"
onClick={() => loginWithEntra()}
disabled={isLoading}
className="w-full h-10 bg-[#2f2f2f] text-white hover:bg-[#1a1a1a] font-medium rounded-lg gap-2.5 mb-4"
>
<svg className="w-4 h-4" viewBox="0 0 21 21" fill="none">
<rect x="1" y="1" width="9" height="9" fill="#F25022" />
<rect x="11" y="1" width="9" height="9" fill="#7FBA00" />
<rect x="1" y="11" width="9" height="9" fill="#00A4EF" />
<rect x="11" y="11" width="9" height="9" fill="#FFB900" />
</svg>
Sign in with Microsoft
</Button>

{/* Divider */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-border-subtle" />
<span className="text-[11px] text-text-tertiary uppercase tracking-wider">or</span>
<div className="flex-1 h-px bg-border-subtle" />
</div>

{/* Mode Toggle */}
<div className="flex p-1 mb-6 rounded-lg bg-surface-ground border border-border-subtle">
<button
Expand Down
71 changes: 71 additions & 0 deletions app/api/auth/entra/callback/route.ts
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;
}
}
44 changes: 44 additions & 0 deletions app/api/auth/entra/login/route.ts
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;
}
48 changes: 48 additions & 0 deletions app/api/auth/entra/logout/route.ts
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;
}
87 changes: 87 additions & 0 deletions app/api/auth/entra/refresh/route.ts
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;
Comment thread
vshekar marked this conversation as resolved.

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;
}
59 changes: 59 additions & 0 deletions app/api/auth/session/route.ts
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;
}
Loading