Add Docker Registry AIO with Nuxt Token Auth and admin UI.

Compose brings up registry:3 plus a Prisma/SQLite Nuxt app for docker login, ACL management, and image browsing with shadcn-vue.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ysj7705andCursor committed 2026-07-30 16:12:12 +09:00
commit 0fd00bfa37
120 files changed
+17094

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod'
const bodySchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
export default defineEventHandler(async (event) => {
await ensureBootstrap()
const body = bodySchema.parse(await readBody(event))
const user = await prisma.user.findUnique({ where: { username: body.username } })
if (!user || !user.active) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
const ok = await verifyPassword(body.password, user.passwordHash)
if (!ok) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
const token = await createSessionToken({
id: user.id,
username: user.username,
role: user.role,
})
setSessionCookie(event, token)
return {
id: user.id,
username: user.username,
role: user.role,
}
})
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
clearSessionCookie(event)
return { ok: true }
})
+7
View File
@@ -0,0 +1,7 @@
export default defineEventHandler(async (event) => {
const user = await readSessionUser(event)
if (!user) {
return { user: null }
}
return { user }
})
+3
View File
@@ -0,0 +1,3 @@
export default defineEventHandler(() => {
return { ok: true, service: 'registry-aio' }
})
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod'
const bodySchema = z.object({
name: z.string().min(1),
tag: z.string().min(1),
})
export default defineEventHandler(async (event) => {
const session = await requireSession(event)
await ensureBootstrap()
const body = bodySchema.parse(await readBody(event))
if (session.role !== 'ADMIN') {
const user = await prisma.user.findUnique({
where: { id: session.id },
include: { acls: true },
})
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const allowed = allowedActionsForRepo(user, body.name)
if (!allowed.includes('delete') && !allowed.includes('*')) {
throw createError({ statusCode: 403, statusMessage: 'Delete not allowed' })
}
}
const digest = await getManifestDigest(body.name, body.tag)
if (!digest) {
throw createError({ statusCode: 404, statusMessage: 'Tag not found' })
}
await deleteManifest(body.name, digest)
return { ok: true, name: body.name, tag: body.tag, digest }
})
+13
View File
@@ -0,0 +1,13 @@
export default defineEventHandler(async (event) => {
await requireSession(event)
await ensureBootstrap()
const query = getQuery(event)
const name = typeof query.name === 'string' ? query.name : ''
if (!name) {
throw createError({ statusCode: 400, statusMessage: 'Missing name query parameter' })
}
const tags = await listTags(name)
return { name, tags }
})
+16
View File
@@ -0,0 +1,16 @@
export default defineEventHandler(async (event) => {
await requireSession(event)
await ensureBootstrap()
try {
const repositories = await listRepositories()
return { repositories }
} catch (err: any) {
// Registry may be unreachable during local UI-only boot
if (err?.statusCode) throw err
throw createError({
statusCode: 502,
statusMessage: err?.message || 'Failed to reach registry',
})
}
})
+16
View File
@@ -0,0 +1,16 @@
export default defineEventHandler(async (event) => {
await requireSession(event)
await ensureBootstrap()
const [userCount, repositories] = await Promise.all([
prisma.user.count(),
listRepositories().catch(() => [] as string[]),
])
return {
users: userCount,
repositories: repositories.length,
authPublicUrl: process.env.AUTH_PUBLIC_URL || '',
registryInternalUrl: process.env.REGISTRY_INTERNAL_URL || '',
}
})
+25
View File
@@ -0,0 +1,25 @@
export default defineEventHandler(async (event) => {
const session = await requireAdmin(event)
await ensureBootstrap()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, statusMessage: 'Missing id' })
}
if (session.id === id) {
throw createError({ statusCode: 400, statusMessage: 'Cannot delete yourself' })
}
const existing = await prisma.user.findUnique({ where: { id } })
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'User not found' })
}
if (existing.username === (process.env.ADMIN_USERNAME || 'admin')) {
throw createError({ statusCode: 400, statusMessage: 'Cannot delete bootstrap admin' })
}
await prisma.user.delete({ where: { id } })
return { ok: true }
})
+29
View File
@@ -0,0 +1,29 @@
export default defineEventHandler(async (event) => {
await requireAdmin(event)
await ensureBootstrap()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, statusMessage: 'Missing id' })
}
const user = await prisma.user.findUnique({
where: { id },
include: { acls: true },
})
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'User not found' })
}
return {
user: {
id: user.id,
username: user.username,
role: user.role,
active: user.active,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
acls: user.acls,
},
}
})
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod'
const aclSchema = z.object({
repository: z.string().min(1),
canPull: z.boolean().default(true),
canPush: z.boolean().default(false),
canDelete: z.boolean().default(false),
})
const bodySchema = z.object({
password: z.string().min(6).optional(),
role: z.enum(['ADMIN', 'USER']).optional(),
active: z.boolean().optional(),
acls: z.array(aclSchema).optional(),
})
export default defineEventHandler(async (event) => {
const session = await requireAdmin(event)
await ensureBootstrap()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, statusMessage: 'Missing id' })
}
const existing = await prisma.user.findUnique({ where: { id } })
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'User not found' })
}
const body = bodySchema.parse(await readBody(event))
if (existing.username === (process.env.ADMIN_USERNAME || 'admin') && body.active === false) {
throw createError({ statusCode: 400, statusMessage: 'Cannot deactivate bootstrap admin' })
}
if (session.id === id && body.role && body.role !== 'ADMIN') {
throw createError({ statusCode: 400, statusMessage: 'Cannot demote yourself' })
}
const user = await prisma.$transaction(async (tx) => {
if (body.acls) {
await tx.acl.deleteMany({ where: { userId: id } })
await tx.acl.createMany({
data: body.acls.map((acl) => ({
userId: id,
repository: acl.repository,
canPull: acl.canPull,
canPush: acl.canPush,
canDelete: acl.canDelete,
})),
})
}
return tx.user.update({
where: { id },
data: {
...(body.role ? { role: body.role } : {}),
...(typeof body.active === 'boolean' ? { active: body.active } : {}),
...(body.password ? { passwordHash: await hashPassword(body.password) } : {}),
},
include: { acls: true },
})
})
return {
user: {
id: user.id,
username: user.username,
role: user.role,
active: user.active,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
acls: user.acls,
},
}
})
+21
View File
@@ -0,0 +1,21 @@
export default defineEventHandler(async (event) => {
await requireAdmin(event)
await ensureBootstrap()
const users = await prisma.user.findMany({
orderBy: { username: 'asc' },
include: { acls: true },
})
return {
users: users.map((u) => ({
id: u.id,
username: u.username,
role: u.role,
active: u.active,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
acls: u.acls,
})),
}
})
+61
View File
@@ -0,0 +1,61 @@
import { z } from 'zod'
const aclSchema = z.object({
repository: z.string().min(1),
canPull: z.boolean().default(true),
canPush: z.boolean().default(false),
canDelete: z.boolean().default(false),
})
const bodySchema = z.object({
username: z
.string()
.min(1)
.max(64)
.regex(/^[a-zA-Z0-9._-]+$/, 'Username may contain letters, numbers, . _ -'),
password: z.string().min(6),
role: z.enum(['ADMIN', 'USER']).default('USER'),
active: z.boolean().default(true),
acls: z.array(aclSchema).default([]),
})
export default defineEventHandler(async (event) => {
await requireAdmin(event)
await ensureBootstrap()
const body = bodySchema.parse(await readBody(event))
const existing = await prisma.user.findUnique({ where: { username: body.username } })
if (existing) {
throw createError({ statusCode: 409, statusMessage: 'Username already exists' })
}
const user = await prisma.user.create({
data: {
username: body.username,
passwordHash: await hashPassword(body.password),
role: body.role,
active: body.active,
acls: {
create: body.acls.map((acl) => ({
repository: acl.repository,
canPull: acl.canPull,
canPush: acl.canPush,
canDelete: acl.canDelete,
})),
},
},
include: { acls: true },
})
return {
user: {
id: user.id,
username: user.username,
role: user.role,
active: user.active,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
acls: user.acls,
},
}
})
+8
View File
@@ -0,0 +1,8 @@
export default defineNitroPlugin(async () => {
try {
await ensureBootstrap()
console.log('[bootstrap] admin ready')
} catch (err) {
console.error('[bootstrap] failed on startup', err)
}
})
+88
View File
@@ -0,0 +1,88 @@
export default defineEventHandler(async (event) => {
await ensureBootstrap()
const query = getQuery(event)
let scopes = parseScopes(query.scope as string | string[] | undefined)
let account = typeof query.account === 'string' ? query.account : undefined
const offline =
query.offline_token === 'true' ||
query.offline_token === true ||
query.access_type === 'offline'
let username: string | undefined
let password: string | undefined
let refreshTokenIn: string | undefined
const basic = parseBasicAuth(getHeader(event, 'authorization'))
if (basic) {
username = basic.username
password = basic.password
}
if (event.method === 'POST') {
const body = ((await readBody(event).catch(() => null)) || {}) as Record<string, any>
username = username || body.username || body.account
password = password || body.password
refreshTokenIn = typeof body.refresh_token === 'string' ? body.refresh_token : undefined
account = account || body.account || username
if (body.scope) {
scopes = parseScopes(body.scope)
}
}
let user =
null as null | Awaited<ReturnType<typeof prisma.user.findUnique<{ include: { acls: true } }>>>
if (refreshTokenIn) {
const refreshUser = await verifyRefreshToken(refreshTokenIn)
if (!refreshUser) {
throw createError({ statusCode: 401, statusMessage: 'Invalid refresh token' })
}
user = await prisma.user.findUnique({
where: { username: refreshUser },
include: { acls: true },
})
} else {
if (!username || !password) {
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
}
user = await prisma.user.findUnique({
where: { username },
include: { acls: true },
})
if (!user || !user.active) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
const ok = await verifyPassword(password, user.passwordHash)
if (!ok) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
}
if (!user || !user.active) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
if (account && account !== user.username) {
throw createError({ statusCode: 401, statusMessage: 'Account mismatch' })
}
const access = authorizeScopes(user, scopes)
const { token, expiresIn, issuedAt } = await signRegistryToken({
subject: user.username,
access,
})
const response: Record<string, unknown> = {
token,
access_token: token,
expires_in: expiresIn,
issued_at: issuedAt,
}
if (offline || refreshTokenIn) {
response.refresh_token = await createRefreshToken(user.username)
}
return response
})
+41
View File
@@ -0,0 +1,41 @@
/**
* Glob-like repository matching:
* - exact match
* - `*` matches a single path segment (no `/`)
* - `**` matches any remaining path
*/
export function matchRepository(pattern: string, repository: string): boolean {
if (pattern === '*' || pattern === '**') return true
if (pattern === repository) return true
const escapeRegex = (s: string) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&')
const regexSource = pattern
.split('/')
.map((segment) => {
if (segment === '**') return '(.+)'
if (segment === '*') return '([^/]+)'
return escapeRegex(segment).replace(/\\\*/g, '[^/]*')
})
.join('/')
return new RegExp(`^${regexSource}$`).test(repository)
}
export type Action = 'pull' | 'push' | 'delete' | '*'
export function actionsFromAcl(acl: {
canPull: boolean
canPush: boolean
canDelete: boolean
}): Action[] {
const actions: Action[] = []
if (acl.canPull) actions.push('pull')
if (acl.canPush) actions.push('push')
if (acl.canDelete) actions.push('delete')
return actions
}
export function intersectActions(requested: string[], allowed: string[]): string[] {
if (allowed.includes('*')) return requested
return requested.filter((a) => allowed.includes(a) || (a === '*' && allowed.length > 0))
}
+107
View File
@@ -0,0 +1,107 @@
import type { Acl, Role, User } from '../../generated/prisma/client'
import {
actionsFromAcl,
intersectActions,
matchRepository,
type Action,
} from './acl'
import type { RegistryAccess } from './token'
export type UserWithAcls = User & { acls: Acl[] }
export type ParsedScope = {
type: string
name: string
actions: string[]
}
/** Parse Docker scope strings like `repository:library/nginx:pull,push` */
export function parseScopes(scopeParam: string | string[] | undefined): ParsedScope[] {
if (!scopeParam) return []
const raw = Array.isArray(scopeParam) ? scopeParam : [scopeParam]
const scopes: ParsedScope[] = []
for (const item of raw) {
for (const part of item.split(' ')) {
if (!part) continue
const first = part.indexOf(':')
const last = part.lastIndexOf(':')
if (first < 0 || last <= first) continue
const type = part.slice(0, first)
const name = part.slice(first + 1, last)
const actions = part
.slice(last + 1)
.split(',')
.map((a) => a.trim())
.filter(Boolean)
scopes.push({ type, name, actions })
}
}
return scopes
}
export function allowedActionsForRepo(user: UserWithAcls, repository: string): Action[] {
if (user.role === 'ADMIN') {
return ['pull', 'push', 'delete', '*']
}
const allowed = new Set<Action>()
for (const acl of user.acls) {
if (!matchRepository(acl.repository, repository)) continue
for (const action of actionsFromAcl(acl)) {
allowed.add(action)
}
}
return [...allowed]
}
export function authorizeScopes(user: UserWithAcls, scopes: ParsedScope[]): RegistryAccess[] {
const access: RegistryAccess[] = []
for (const scope of scopes) {
if (user.role === 'ADMIN') {
access.push({
type: scope.type,
name: scope.name,
actions: scope.actions,
})
continue
}
if (scope.type !== 'repository') {
continue
}
const allowed = allowedActionsForRepo(user, scope.name)
const granted = intersectActions(scope.actions, allowed)
if (granted.length > 0) {
access.push({
type: scope.type,
name: scope.name,
actions: granted,
})
}
}
return access
}
export function parseBasicAuth(header: string | undefined): { username: string; password: string } | null {
if (!header?.startsWith('Basic ')) return null
try {
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
const idx = decoded.indexOf(':')
if (idx < 0) return null
return {
username: decoded.slice(0, idx),
password: decoded.slice(idx + 1),
}
} catch {
return null
}
}
export function assertRole(role: string): Role {
if (role === 'ADMIN' || role === 'USER') return role
throw createError({ statusCode: 400, statusMessage: 'Invalid role' })
}
+22
View File
@@ -0,0 +1,22 @@
import { ensureTokenCerts } from './certs'
import { prisma } from './prisma'
import { seedAdmin } from '../../prisma/seed'
let ready: Promise<void> | null = null
async function bootstrap() {
ensureTokenCerts()
await prisma.$connect()
await seedAdmin(prisma)
}
export async function ensureBootstrap() {
if (!ready) {
ready = bootstrap().catch((err) => {
console.error('[bootstrap] failed', err)
ready = null
throw err
})
}
await ready
}
+52
View File
@@ -0,0 +1,52 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
function resolvePath(envName: string, fallback: string): string {
return resolve(process.env[envName] || fallback)
}
/** Token Auth용 RSA 키/인증서가 없으면 openssl로 생성합니다. */
export function ensureTokenCerts(): { keyPath: string; certPath: string } {
const keyPath = resolvePath('TOKEN_KEY_PATH', '../certs/token.key')
const certPath = resolvePath('TOKEN_CERT_PATH', '../certs/token.crt')
if (existsSync(keyPath) && existsSync(certPath)) {
return { keyPath, certPath }
}
mkdirSync(dirname(keyPath), { recursive: true })
mkdirSync(dirname(certPath), { recursive: true })
const result = spawnSync(
'openssl',
[
'req',
'-new',
'-newkey',
'rsa:4096',
'-days',
'3650',
'-nodes',
'-x509',
'-subj',
'/CN=registry-aio-token',
'-keyout',
keyPath,
'-out',
certPath,
],
{ encoding: 'utf8' },
)
if (result.error || result.status !== 0) {
const detail = result.error?.message || result.stderr || result.stdout || 'unknown error'
throw new Error(
`Failed to generate token certs at ${keyPath} / ${certPath}. ` +
`Install openssl (Compose certs 서비스가 자동 생성합니다). (${detail})`,
)
}
console.info(`[certs] generated ${keyPath} and ${certPath}`)
return { keyPath, certPath }
}
+11
View File
@@ -0,0 +1,11 @@
import bcrypt from 'bcryptjs'
const ROUNDS = 10
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, ROUNDS)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
+19
View File
@@ -0,0 +1,19 @@
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { PrismaClient } from '../../generated/prisma/client'
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
function createPrismaClient() {
const url = process.env.DATABASE_URL || 'file:./dev.db'
const adapter = new PrismaBetterSqlite3({ url })
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
})
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
+26
View File
@@ -0,0 +1,26 @@
import { SignJWT, jwtVerify } from 'jose'
import { randomUUID } from 'node:crypto'
function refreshSecret(): Uint8Array {
const secret = process.env.SESSION_SECRET || 'dev-session-secret-change-me'
return new TextEncoder().encode(`refresh:${secret}`)
}
export async function createRefreshToken(username: string): Promise<string> {
return new SignJWT({ typ: 'refresh', username })
.setProtectedHeader({ alg: 'HS256' })
.setJti(randomUUID())
.setIssuedAt()
.setExpirationTime('30d')
.sign(refreshSecret())
}
export async function verifyRefreshToken(token: string): Promise<string | null> {
try {
const { payload } = await jwtVerify(token, refreshSecret())
if (payload.typ !== 'refresh' || typeof payload.username !== 'string') return null
return payload.username
} catch {
return null
}
}
+97
View File
@@ -0,0 +1,97 @@
const DEFAULT_REGISTRY_URL = 'http://registry:5000'
export function registryBaseUrl(): string {
return (process.env.REGISTRY_INTERNAL_URL || DEFAULT_REGISTRY_URL).replace(/\/$/, '')
}
async function serviceToken(access: RegistryAccess[]): Promise<string> {
const { token } = await signRegistryToken({
subject: 'registry-aio-service',
access,
expiresInSeconds: 120,
})
return token
}
export async function registryFetch(path: string, init: RequestInit = {}, access?: RegistryAccess[]) {
const url = `${registryBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`
const headers = new Headers(init.headers)
if (!headers.has('Accept')) {
headers.set(
'Accept',
'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/json',
)
}
const granted =
access ??
([
{ type: 'registry', name: 'catalog', actions: ['*'] },
{ type: 'repository', name: '**', actions: ['*'] },
] satisfies RegistryAccess[])
// Distribution matches exact repository names in token access — for service calls
// we mint tokens with requested repo scopes per operation instead of '**'.
const token = await serviceToken(granted)
headers.set('Authorization', `Bearer ${token}`)
return fetch(url, { ...init, headers })
}
export async function listRepositories(): Promise<string[]> {
const res = await registryFetch(
'/v2/_catalog?n=1000',
{},
[{ type: 'registry', name: 'catalog', actions: ['*'] }],
)
if (!res.ok) {
throw createError({
statusCode: 502,
statusMessage: `Registry catalog failed: ${res.status}`,
})
}
const data = (await res.json()) as { repositories?: string[] }
return data.repositories ?? []
}
export async function listTags(name: string): Promise<string[]> {
const res = await registryFetch(
`/v2/${encodeURI(name)}/tags/list`,
{},
[{ type: 'repository', name, actions: ['pull'] }],
)
if (res.status === 404) return []
if (!res.ok) {
throw createError({
statusCode: 502,
statusMessage: `Registry tags failed: ${res.status}`,
})
}
const data = (await res.json()) as { tags?: string[] | null }
return data.tags ?? []
}
export async function getManifestDigest(name: string, reference: string): Promise<string | null> {
const res = await registryFetch(
`/v2/${encodeURI(name)}/manifests/${encodeURIComponent(reference)}`,
{ method: 'HEAD' },
[{ type: 'repository', name, actions: ['pull'] }],
)
if (!res.ok) return null
return res.headers.get('docker-content-digest')
}
export async function deleteManifest(name: string, digest: string): Promise<void> {
const res = await registryFetch(
`/v2/${encodeURI(name)}/manifests/${encodeURIComponent(digest)}`,
{ method: 'DELETE' },
[{ type: 'repository', name, actions: ['delete', 'pull'] }],
)
if (!res.ok && res.status !== 404) {
throw createError({
statusCode: 502,
statusMessage: `Registry delete failed: ${res.status}`,
})
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { H3Event } from 'h3'
import { SignJWT, jwtVerify } from 'jose'
import type { Role } from '../../generated/prisma/client'
export type SessionUser = {
id: string
username: string
role: Role
}
const COOKIE_NAME = 'registry_aio_session'
function getSecret(): Uint8Array {
const secret = process.env.SESSION_SECRET || 'dev-session-secret-change-me'
return new TextEncoder().encode(secret)
}
export async function createSessionToken(user: SessionUser): Promise<string> {
return new SignJWT({
id: user.id,
username: user.username,
role: user.role,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(getSecret())
}
export async function readSessionUser(event: H3Event): Promise<SessionUser | null> {
const token = getCookie(event, COOKIE_NAME)
if (!token) return null
try {
const { payload } = await jwtVerify(token, getSecret())
if (
typeof payload.id !== 'string' ||
typeof payload.username !== 'string' ||
(payload.role !== 'ADMIN' && payload.role !== 'USER')
) {
return null
}
return {
id: payload.id,
username: payload.username,
role: payload.role,
}
} catch {
return null
}
}
export function setSessionCookie(event: H3Event, token: string) {
setCookie(event, COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
secure: process.env.AUTH_PUBLIC_URL?.startsWith('https://') ?? false,
})
}
export function clearSessionCookie(event: H3Event) {
deleteCookie(event, COOKIE_NAME, { path: '/' })
}
export async function requireSession(event: H3Event): Promise<SessionUser> {
const user = await readSessionUser(event)
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
return user
}
export async function requireAdmin(event: H3Event): Promise<SessionUser> {
const user = await requireSession(event)
if (user.role !== 'ADMIN') {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
}
return user
}
+87
View File
@@ -0,0 +1,87 @@
import {
createPrivateKey,
randomUUID,
X509Certificate,
} from 'node:crypto'
import { readFileSync } from 'node:fs'
import { SignJWT, importPKCS8 } from 'jose'
import { ensureTokenCerts } from './certs'
export type RegistryAccess = {
type: string
name: string
actions: string[]
}
type TokenMaterial = {
privateKeyPem: string
x5c: string
}
let cached: TokenMaterial | null = null
function toPkcs8Pem(privateKeyPem: string): string {
const key = createPrivateKey(privateKeyPem)
return key.export({ type: 'pkcs8', format: 'pem' }).toString()
}
function pemToX5C(certPem: string): string {
return certPem
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s+/g, '')
}
function loadTokenMaterial(): TokenMaterial {
if (cached) return cached
const { keyPath, certPath } = ensureTokenCerts()
const rawPrivateKeyPem = readFileSync(keyPath, 'utf8')
const privateKeyPem = toPkcs8Pem(rawPrivateKeyPem)
const certPem = readFileSync(certPath, 'utf8')
// Validate certificate parses
void new X509Certificate(certPem)
cached = {
privateKeyPem,
x5c: pemToX5C(certPem),
}
return cached
}
export async function signRegistryToken(options: {
subject: string
access: RegistryAccess[]
expiresInSeconds?: number
}): Promise<{ token: string; expiresIn: number; issuedAt: string }> {
const { privateKeyPem, x5c } = loadTokenMaterial()
const privateKey = await importPKCS8(privateKeyPem, 'RS256')
const issuer = process.env.REGISTRY_AUTH_ISSUER || 'registry-aio'
const audience = process.env.REGISTRY_AUTH_SERVICE || 'registry'
const expiresIn = options.expiresInSeconds ?? 600
const now = Math.floor(Date.now() / 1000)
const issuedAt = new Date(now * 1000).toISOString()
const token = await new SignJWT({
access: options.access,
})
.setProtectedHeader({
alg: 'RS256',
typ: 'JWT',
// Registry v3 trusts tokens via x5c against rootcertbundle
x5c: [x5c],
})
.setIssuer(issuer)
.setSubject(options.subject)
.setAudience(audience)
.setIssuedAt(now)
.setNotBefore(now - 10)
.setExpirationTime(now + expiresIn)
.setJti(randomUUID())
.sign(privateKey)
return { token, expiresIn, issuedAt }
}