diff --git a/apps/api/src/lib/target-app.ts b/apps/api/src/lib/target-app.ts index a6d8855..eacd04d 100644 --- a/apps/api/src/lib/target-app.ts +++ b/apps/api/src/lib/target-app.ts @@ -1,19 +1,32 @@ import type { AuditSourceApp } from '@authportal/shared' -/** Resolve SSO target app from return_to URL host/path. */ +/** Resolve SSO target app from return_to URL host/path (and OIDC authorize unwrap). */ export function targetAppFromReturnTo( returnTo: string | undefined | null, ): AuditSourceApp { if (!returnTo) return 'portal' let host = '' let path = '' + let search = '' try { const u = new URL(returnTo) host = u.hostname.toLowerCase() path = u.pathname.toLowerCase() + search = u.search } catch { return 'portal' } + + // Portal OIDC authorize as return_to → map via client's redirect_uri + if (path === '/oauth/authorize' || path.endsWith('/oauth/authorize')) { + try { + const redirectUri = new URLSearchParams(search).get('redirect_uri') + if (redirectUri) return targetAppFromReturnTo(redirectUri) + } catch { + /* ignore */ + } + } + const hay = `${host} ${path}` if (/\bvps\b/.test(hay) || host.includes('vps')) return 'vps' if ( diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts index 78bad7b..c816f42 100644 --- a/apps/api/src/routes/oidc.ts +++ b/apps/api/src/routes/oidc.ts @@ -18,6 +18,7 @@ import { normalizePermissionKeys, } from '@authportal/shared' import { oidcIssuerFromConfig } from '../config.js' +import { clientIp, safeAudit } from '../lib/audit.js' import { buildJwks, buildOidcDiscovery, @@ -25,6 +26,7 @@ import { signOidcJwt, verifyOidcAccessToken, } from '../lib/oidc/keys.js' +import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js' const REFRESH_COOKIE = 'refresh_token' const CODE_TTL_MS = 5 * 60 * 1000 @@ -266,6 +268,27 @@ export async function oidcRoutes(app: FastifyInstance): Promise { expiresAt: new Date(Date.now() + CODE_TTL_MS), }) + const targetApp = targetAppFromReturnTo(redirectUri) + safeAudit(app, { + action: 'auth.sso_handoff', + severity: 'info', + actorUserId: user.id, + actorEmail: user.email, + actorName: user.name, + targetType: 'session', + targetId: user.id, + summary: `SSO OIDC: ${user.email} → ${targetApp}`, + details: { + return_to: redirectUri, + target_app: targetApp, + user_agent: clientUserAgent(request.headers), + oidc_client_id: client.clientId, + oidc_client_name: client.name, + auth_mode: 'oidc', + }, + ip: clientIp(request), + }) + const dest = new URL(redirectUri) dest.searchParams.set('code', rawCode) if (state) dest.searchParams.set('state', state) diff --git a/apps/api/test/oidc.test.ts b/apps/api/test/oidc.test.ts index fae2ce1..3c51f9e 100644 --- a/apps/api/test/oidc.test.ts +++ b/apps/api/test/oidc.test.ts @@ -50,6 +50,66 @@ describe('OIDC IdP', () => { await app.close() }) + it('records auth.sso_handoff with target_app dns on authorize', async () => { + const app = await buildTestApp() + + const login = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: 'admin@test.local', password: 'adminpass' }, + }) + expect(login.statusCode).toBe(200) + const token = (login.json() as { access_token: string }).access_token + const refresh = login.cookies.find((c) => c.name === 'refresh_token') + expect(refresh?.value).toBeTruthy() + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/admin/oidc/clients', + headers: { authorization: `Bearer ${token}` }, + payload: { + name: 'Technitium', + redirect_uris: ['https://dns.test.local/sso/callback'], + scopes: ['openid', 'profile', 'email', 'groups'], + enabled: true, + }, + }) + const client = created.json() as { client_id: string } + + const authorize = await app.inject({ + method: 'GET', + url: '/oauth/authorize', + cookies: { refresh_token: refresh!.value }, + query: { + client_id: client.client_id, + redirect_uri: 'https://dns.test.local/sso/callback', + response_type: 'code', + scope: 'openid profile email groups', + state: 'xyz', + }, + }) + expect(authorize.statusCode).toBe(302) + + const audit = await app.inject({ + method: 'GET', + url: '/api/v1/admin/audit?kind=logins&limit=50', + headers: { authorization: `Bearer ${token}` }, + }) + expect(audit.statusCode).toBe(200) + const entries = audit.json() as { + action: string + details: Record | null + }[] + const handoff = entries.find( + (e) => + e.action === 'auth.sso_handoff' && + e.details?.target_app === 'dns' && + e.details?.auth_mode === 'oidc', + ) + expect(handoff).toBeTruthy() + await app.close() + }) + it('reloads RS256 key from SQLite after restart (extractable import)', async () => { const dir = mkdtempSync(join(tmpdir(), 'oidc-key-')) const dbPath = `sqlite:${join(dir, 'app.db')}` diff --git a/apps/api/test/target-app.test.ts b/apps/api/test/target-app.test.ts new file mode 100644 index 0000000..cfecc99 --- /dev/null +++ b/apps/api/test/target-app.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { targetAppFromReturnTo } from '../src/lib/target-app.js' + +describe('targetAppFromReturnTo', () => { + it('maps dns host and /sso/ path', () => { + expect(targetAppFromReturnTo('https://dns.shnt.top/sso/callback')).toBe( + 'dns', + ) + }) + + it('unwraps portal /oauth/authorize return_to via redirect_uri', () => { + const returnTo = + 'https://auth.shnt.top/oauth/authorize?client_id=abc&redirect_uri=' + + encodeURIComponent('https://dns.shnt.top/sso/callback') + + '&response_type=code&scope=openid' + expect(targetAppFromReturnTo(returnTo)).toBe('dns') + }) + + it('keeps portal for bare issuer authorize without redirect_uri', () => { + expect( + targetAppFromReturnTo('https://auth.shnt.top/oauth/authorize'), + ).toBe('portal') + }) +}) diff --git a/docs/integrate-technitium.md b/docs/integrate-technitium.md index 17aa434..9d68018 100644 --- a/docs/integrate-technitium.md +++ b/docs/integrate-technitium.md @@ -88,6 +88,8 @@ curl -fsS http://localhost:8080/.well-known/openid-configuration | head Для `dns` режим `authMode: oidc` — открывается базовый URL (кнопка OpenID Connect на логине Technitium), без `#access_token`. +Журнал входов портала: при выдаче authorization code пишется `auth.sso_handoff` с `target_app: dns` (колонка «Приложение» → Technitium DNS). + ## Endpoints portal (IdP) - `GET /.well-known/openid-configuration`