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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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://<host>/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'
|
||||
|
||||
@@ -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://<host>/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>
|
||||
|
||||
Reference in New Issue
Block a user