feat(oidc): add CreateOidcClientSheet component and update imports
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m25s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

- 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 <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-11 19:40:39 +07:00
co-authored by Cursor
parent 0b6fa65b08
commit 36c4091dbf
5 changed files with 251 additions and 101 deletions
+24 -2
View File
@@ -40,8 +40,26 @@ function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK {
async function materialFromPem(
kid: string,
privatePem: string,
publicJwkJson?: string,
): Promise<OidcKeyMaterial> {
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
}
+48
View File
@@ -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()
@@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Новый OIDC-клиент
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
Redirect URI для Technitium:{' '}
<code className="text-xs">https://&lt;host&gt;/sso/callback</code>
</SheetDescription>
</SheetHeader>
<form
id="create-oidc-client-form"
className="flex min-h-0 flex-1 flex-col"
onSubmit={(e) => {
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,
})
}}
>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
<FieldGroup>
<Field>
<FieldLabel htmlFor="oidc-name">Название</FieldLabel>
<Input
id="oidc-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoComplete="off"
/>
</Field>
<Field>
<FieldLabel htmlFor="oidc-redirects">
Redirect URIs
</FieldLabel>
<Textarea
id="oidc-redirects"
name="redirect_uris"
className="min-h-24"
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
placeholder="https://dns.example.com/sso/callback"
required
/>
<p className="text-muted-foreground text-xs">
По одному URI на строку
</p>
</Field>
<Field orientation="horizontal" className="items-center justify-between">
<FieldLabel htmlFor="oidc-enabled" className="font-normal">
Включён
</FieldLabel>
<Switch
id="oidc-enabled"
checked={enabled}
onCheckedChange={(v: boolean) => setEnabled(v === true)}
/>
</Field>
</FieldGroup>
</div>
<SheetFooter className="bg-background shrink-0 border-t">
<div className="flex w-full gap-2">
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<Button
type="submit"
form="create-oidc-client-form"
className="min-w-0 flex-1"
disabled={pending || !name.trim() || !redirectUris.trim()}
>
{pending ? 'Создание…' : 'Создать'}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
@@ -15,4 +15,5 @@ export { AppSwitcherAdminEditor } from './app-switcher-admin-editor'
export { UserAccessSheet } from './user-access-sheet'
export { UserAuditSheet } from './user-audit-sheet'
export { CreateUserSheet } from './create-user-sheet'
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
+4 -99
View File
@@ -1,6 +1,7 @@
/**
* Admin OIDC clients — Frame surface.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/data-grid-filtering-2
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/sheet-8
* Docs: https://reui.io/blocks
*/
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -17,18 +18,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Skeleton } from '@authportal/ui/components/skeleton'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import {
AlertDialog,
AlertDialogAction,
@@ -40,6 +30,7 @@ import {
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Badge } from '@/components/reui/badge'
import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet'
import { ApiError } from '@/lib/api-client'
import {
createOidcClient,
@@ -305,92 +296,6 @@ function OidcClientRow({
)
}
function CreateOidcClientSheet({
open,
onOpenChange,
pending,
onSubmit,
}: {
open: boolean
onOpenChange: (o: boolean) => void
pending: boolean
onSubmit: (v: {
name: string
redirect_uris: string[]
scopes: Array<'openid' | 'profile' | 'email' | 'groups'>
enabled: boolean
}) => void
}) {
const [name, setName] = useState('Technitium DNS')
const [redirectUris, setRedirectUris] = useState(
'https://dns.shnt.top/sso/callback',
)
const [enabled, setEnabled] = useState(true)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
<SheetHeader>
<SheetTitle>Новый OIDC-клиент</SheetTitle>
<SheetDescription>
Redirect URI для Technitium:{' '}
<code className="text-xs">https://&lt;host&gt;/sso/callback</code>
</SheetDescription>
</SheetHeader>
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor="oidc-name">Название</FieldLabel>
<Input
id="oidc-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="oidc-redirects">
Redirect URIs (по одному на строку)
</FieldLabel>
<textarea
id="oidc-redirects"
className="border-input bg-background min-h-24 w-full rounded-md border px-3 py-2 text-sm"
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
/>
</Field>
<Field className="flex flex-row items-center justify-between gap-3">
<FieldLabel htmlFor="oidc-enabled">Включён</FieldLabel>
<Switch
id="oidc-enabled"
checked={enabled}
onCheckedChange={(v: boolean) => setEnabled(v === true)}
/>
</Field>
</FieldGroup>
<SheetFooter>
<Button
type="button"
disabled={pending || !name.trim()}
onClick={() => {
const uris = redirectUris
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
onSubmit({
name: name.trim(),
redirect_uris: uris,
scopes: ['openid', 'profile', 'email', 'groups'],
enabled,
})
}}
>
{pending ? 'Создание…' : 'Создать'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
function SecretRevealDialog({
client,
onClose,
@@ -411,7 +316,7 @@ function SecretRevealDialog({
if (!o) onClose()
}}
>
<AlertDialogContent>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
<AlertDialogDescription>