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 <[email protected]>
This commit is contained in:
@@ -40,8 +40,26 @@ function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK {
|
|||||||
async function materialFromPem(
|
async function materialFromPem(
|
||||||
kid: string,
|
kid: string,
|
||||||
privatePem: string,
|
privatePem: string,
|
||||||
|
publicJwkJson?: string,
|
||||||
): Promise<OidcKeyMaterial> {
|
): 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)
|
const full = await exportJWK(privateKey)
|
||||||
return {
|
return {
|
||||||
kid,
|
kid,
|
||||||
@@ -67,7 +85,11 @@ export async function ensureOidcSigningKey(
|
|||||||
|
|
||||||
const existing = getActiveOidcSigningKey(app.db)
|
const existing = getActiveOidcSigningKey(app.db)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
cached = await materialFromPem(existing.kid, existing.privatePem)
|
cached = await materialFromPem(
|
||||||
|
existing.kid,
|
||||||
|
existing.privatePem,
|
||||||
|
existing.publicJwkJson,
|
||||||
|
)
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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 { jwtVerify, createLocalJWKSet } from 'jose'
|
||||||
import { buildApp } from '../src/app.js'
|
import { buildApp } from '../src/app.js'
|
||||||
import { loadConfig } from '../src/config.js'
|
import { loadConfig } from '../src/config.js'
|
||||||
@@ -47,6 +50,51 @@ describe('OIDC IdP', () => {
|
|||||||
await app.close()
|
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: '[email protected]',
|
||||||
|
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 () => {
|
it('authorization code flow issues id_token with groups', async () => {
|
||||||
const app = await buildTestApp()
|
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 { UserAccessSheet } from './user-access-sheet'
|
||||||
export { UserAuditSheet } from './user-audit-sheet'
|
export { UserAuditSheet } from './user-audit-sheet'
|
||||||
export { CreateUserSheet } from './create-user-sheet'
|
export { CreateUserSheet } from './create-user-sheet'
|
||||||
|
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
|
||||||
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Admin OIDC clients — Frame surface.
|
* 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 { useMemo, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -17,18 +18,7 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Button } from '@authportal/ui/components/button'
|
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 { Skeleton } from '@authportal/ui/components/skeleton'
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@authportal/ui/components/sheet'
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -40,6 +30,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@authportal/ui/components/alert-dialog'
|
} from '@authportal/ui/components/alert-dialog'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet'
|
||||||
import { ApiError } from '@/lib/api-client'
|
import { ApiError } from '@/lib/api-client'
|
||||||
import {
|
import {
|
||||||
createOidcClient,
|
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({
|
function SecretRevealDialog({
|
||||||
client,
|
client,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -411,7 +316,7 @@ function SecretRevealDialog({
|
|||||||
if (!o) onClose()
|
if (!o) onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent size="sm">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
|
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
|
|||||||
Reference in New Issue
Block a user