feat(oidc): enhance SSO target app resolution and audit logging
- Updated targetAppFromReturnTo function to handle OIDC authorization unwrap and added search parameter processing. - Integrated target app resolution into the OIDC route for improved audit logging of SSO handoffs. - Added a test case to verify the logging of the target app during the authorization process. - Updated documentation to reflect changes in audit logging for the Technitium DNS application. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,19 +1,32 @@
|
|||||||
import type { AuditSourceApp } from '@authportal/shared'
|
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(
|
export function targetAppFromReturnTo(
|
||||||
returnTo: string | undefined | null,
|
returnTo: string | undefined | null,
|
||||||
): AuditSourceApp {
|
): AuditSourceApp {
|
||||||
if (!returnTo) return 'portal'
|
if (!returnTo) return 'portal'
|
||||||
let host = ''
|
let host = ''
|
||||||
let path = ''
|
let path = ''
|
||||||
|
let search = ''
|
||||||
try {
|
try {
|
||||||
const u = new URL(returnTo)
|
const u = new URL(returnTo)
|
||||||
host = u.hostname.toLowerCase()
|
host = u.hostname.toLowerCase()
|
||||||
path = u.pathname.toLowerCase()
|
path = u.pathname.toLowerCase()
|
||||||
|
search = u.search
|
||||||
} catch {
|
} catch {
|
||||||
return 'portal'
|
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}`
|
const hay = `${host} ${path}`
|
||||||
if (/\bvps\b/.test(hay) || host.includes('vps')) return 'vps'
|
if (/\bvps\b/.test(hay) || host.includes('vps')) return 'vps'
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
normalizePermissionKeys,
|
normalizePermissionKeys,
|
||||||
} from '@authportal/shared'
|
} from '@authportal/shared'
|
||||||
import { oidcIssuerFromConfig } from '../config.js'
|
import { oidcIssuerFromConfig } from '../config.js'
|
||||||
|
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||||
import {
|
import {
|
||||||
buildJwks,
|
buildJwks,
|
||||||
buildOidcDiscovery,
|
buildOidcDiscovery,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
signOidcJwt,
|
signOidcJwt,
|
||||||
verifyOidcAccessToken,
|
verifyOidcAccessToken,
|
||||||
} from '../lib/oidc/keys.js'
|
} from '../lib/oidc/keys.js'
|
||||||
|
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token'
|
const REFRESH_COOKIE = 'refresh_token'
|
||||||
const CODE_TTL_MS = 5 * 60 * 1000
|
const CODE_TTL_MS = 5 * 60 * 1000
|
||||||
@@ -266,6 +268,27 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
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)
|
const dest = new URL(redirectUri)
|
||||||
dest.searchParams.set('code', rawCode)
|
dest.searchParams.set('code', rawCode)
|
||||||
if (state) dest.searchParams.set('state', state)
|
if (state) dest.searchParams.set('state', state)
|
||||||
|
|||||||
@@ -50,6 +50,66 @@ describe('OIDC IdP', () => {
|
|||||||
await app.close()
|
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: '[email protected]', 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<string, unknown> | 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 () => {
|
it('reloads RS256 key from SQLite after restart (extractable import)', async () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'oidc-key-'))
|
const dir = mkdtempSync(join(tmpdir(), 'oidc-key-'))
|
||||||
const dbPath = `sqlite:${join(dir, 'app.db')}`
|
const dbPath = `sqlite:${join(dir, 'app.db')}`
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -88,6 +88,8 @@ curl -fsS http://localhost:8080/.well-known/openid-configuration | head
|
|||||||
|
|
||||||
Для `dns` режим `authMode: oidc` — открывается базовый URL (кнопка OpenID Connect на логине Technitium), без `#access_token`.
|
Для `dns` режим `authMode: oidc` — открывается базовый URL (кнопка OpenID Connect на логине Technitium), без `#access_token`.
|
||||||
|
|
||||||
|
Журнал входов портала: при выдаче authorization code пишется `auth.sso_handoff` с `target_app: dns` (колонка «Приложение» → Technitium DNS).
|
||||||
|
|
||||||
## Endpoints portal (IdP)
|
## Endpoints portal (IdP)
|
||||||
|
|
||||||
- `GET /.well-known/openid-configuration`
|
- `GET /.well-known/openid-configuration`
|
||||||
|
|||||||
Reference in New Issue
Block a user