From 36c4091dbf3323ab6b06ea1557896ab1cb82bd52 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 11 Aug 2026 19:40:39 +0700 Subject: [PATCH] feat(oidc): add CreateOidcClientSheet component and update imports - Introduced the CreateOidcClientSheet component for OIDC client creation. - Updated the index export to include the new component. - Modified the OIDC admin route to import and utilize the new CreateOidcClientSheet. Co-authored-by: Cursor --- apps/api/src/lib/oidc/keys.ts | 26 ++- apps/api/test/oidc.test.ts | 48 +++++ .../reui-kit/create-oidc-client-sheet.tsx | 174 ++++++++++++++++++ apps/web/src/components/reui-kit/index.ts | 1 + apps/web/src/routes/_auth.admin.oidc.tsx | 103 +---------- 5 files changed, 251 insertions(+), 101 deletions(-) create mode 100644 apps/web/src/components/reui-kit/create-oidc-client-sheet.tsx diff --git a/apps/api/src/lib/oidc/keys.ts b/apps/api/src/lib/oidc/keys.ts index 949e975..2a9be4b 100644 --- a/apps/api/src/lib/oidc/keys.ts +++ b/apps/api/src/lib/oidc/keys.ts @@ -40,8 +40,26 @@ function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK { async function materialFromPem( kid: string, privatePem: string, + publicJwkJson?: string, ): Promise { - const privateKey = await importPKCS8(privatePem, 'RS256') + // jose defaults extractable=false for private keys → exportJWK throws + const privateKey = await importPKCS8(privatePem, 'RS256', { + extractable: true, + }) + + if (publicJwkJson) { + try { + const stored = JSON.parse(publicJwkJson) as JWK + return { + kid, + privateKey, + publicJwk: publicJwkFromPrivateExport({ ...stored, kid }, kid), + } + } catch { + // fall through to derive from private key + } + } + const full = await exportJWK(privateKey) return { kid, @@ -67,7 +85,11 @@ export async function ensureOidcSigningKey( const existing = getActiveOidcSigningKey(app.db) if (existing) { - cached = await materialFromPem(existing.kid, existing.privatePem) + cached = await materialFromPem( + existing.kid, + existing.privatePem, + existing.publicJwkJson, + ) return cached } diff --git a/apps/api/test/oidc.test.ts b/apps/api/test/oidc.test.ts index f66f65f..fae2ce1 100644 --- a/apps/api/test/oidc.test.ts +++ b/apps/api/test/oidc.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { jwtVerify, createLocalJWKSet } from 'jose' import { buildApp } from '../src/app.js' import { loadConfig } from '../src/config.js' @@ -47,6 +50,51 @@ describe('OIDC IdP', () => { 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')}` + try { + resetOidcKeyCache() + const config = loadConfig({ + ...process.env, + JWT_SECRET: 'test-secret-at-least-8', + ADMIN_EMAIL: 'admin@test.local', + ADMIN_PASSWORD: 'adminpass', + DATABASE_URL: dbPath, + ISSUER: 'https://auth.test.local', + OIDC_ISSUER: 'https://auth.test.local', + NODE_ENV: 'test', + }) + const first = await buildApp({ config, databaseUrl: dbPath }) + const jwks1 = await first.inject({ + method: 'GET', + url: '/.well-known/jwks.json', + }) + expect(jwks1.statusCode).toBe(200) + const kid = (jwks1.json() as { keys: { kid: string }[] }).keys[0]?.kid + expect(kid).toBeTruthy() + await first.close() + + resetOidcKeyCache() + const second = await buildApp({ config, databaseUrl: dbPath }) + const jwks2 = await second.inject({ + method: 'GET', + url: '/.well-known/jwks.json', + }) + expect(jwks2.statusCode).toBe(200) + expect((jwks2.json() as { keys: { kid: string }[] }).keys[0]?.kid).toBe( + kid, + ) + await second.close() + } finally { + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // Windows may keep better-sqlite3 handle briefly + } + } + }) + it('authorization code flow issues id_token with groups', async () => { const app = await buildTestApp() diff --git a/apps/web/src/components/reui-kit/create-oidc-client-sheet.tsx b/apps/web/src/components/reui-kit/create-oidc-client-sheet.tsx new file mode 100644 index 0000000..23b2c44 --- /dev/null +++ b/apps/web/src/components/reui-kit/create-oidc-client-sheet.tsx @@ -0,0 +1,174 @@ +/** + * Create OIDC client Sheet — auth-portal admin. + * Preview: https://reui.io/preview/base/sheet-8 · https://reui.io/preview/base/solution-users-1 + * Docs: https://reui.io/blocks · https://ui.shadcn.com/docs/components/base/sheet + */ +import { useEffect, useState } from 'react' +import { XIcon } from 'lucide-react' +import { Button } from '@authportal/ui/components/button' +import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field' +import { Input } from '@authportal/ui/components/input' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@authportal/ui/components/sheet' +import { Switch } from '@authportal/ui/components/switch' +import { Textarea } from '@authportal/ui/components/textarea' + +const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground' + +export type CreateOidcClientValues = { + name: string + redirect_uris: string[] + scopes: Array<'openid' | 'profile' | 'email' | 'groups'> + enabled: boolean +} + +export function CreateOidcClientSheet({ + open, + onOpenChange, + pending, + onSubmit, +}: { + open: boolean + onOpenChange: (open: boolean) => void + pending: boolean + onSubmit: (values: CreateOidcClientValues) => void +}) { + const [name, setName] = useState('Technitium DNS') + const [redirectUris, setRedirectUris] = useState( + 'https://dns.shnt.top/sso/callback', + ) + const [enabled, setEnabled] = useState(true) + + useEffect(() => { + if (!open) { + setName('Technitium DNS') + setRedirectUris('https://dns.shnt.top/sso/callback') + setEnabled(true) + } + }, [open]) + + return ( + + + +
+ + Новый OIDC-клиент + + +
+ + Redirect URI для Technitium:{' '} + https://<host>/sso/callback + +
+ +
{ + e.preventDefault() + const uris = redirectUris + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + onSubmit({ + name: name.trim(), + redirect_uris: uris, + scopes: ['openid', 'profile', 'email', 'groups'], + enabled, + }) + }} + > +
+ + + Название + setName(e.target.value)} + required + autoComplete="off" + /> + + + + Redirect URIs + +