Skip to content

Commit ae11f7e

Browse files
author
jarvis
committed
feat(auth): add OIDC administration compatibility
1 parent e20649b commit ae11f7e

7 files changed

Lines changed: 168 additions & 16 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ NUXT_OIDC_REDIRECT_URI=
1414
NUXT_OIDC_SESSION_SECRET=
1515
NUXT_OIDC_SESSION_TTL_SECONDS=28800
1616

17+
# Optional OIDC multi-user administration and identity continuity
18+
# Comma-separated OIDC email claims allowed to run site-wide administration.
19+
NUXT_SITE_ADMIN_EMAILS=
20+
# JSON map from a current OIDC subject to prior owner IDs it may continue to access.
21+
# Example: {"current-subject":["previous-subject"]}
22+
NUXT_LINK_OWNER_ALIASES=
23+
1724
# Required deployment configuration
1825
# Workers Build: set in Workers Builds variables.
1926
# Pages: set in the unified Variables and Secrets.

nuxt.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export default defineNuxtConfig({
2929
oidcSessionSecret: '',
3030
oidcSessionTtlSeconds: 28_800,
3131
oidcAllowInsecure: false,
32+
siteAdminEmails: '',
33+
linkOwnerAliases: '',
3234
redirectStatusCode: '301',
3335
linkCacheTtl: 60,
3436
redirectWithQuery: false,

server/services/link-store/d1.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { drizzle } from 'drizzle-orm/d1'
77
import { createError } from 'h3'
88
import { parseURL, stringifyParsedURL } from 'ufo'
99
import { links, linkTags, linkTombstones, tags } from '../../database/schema'
10+
import { getCurrentLinkOwnerId, getCurrentLinkOwnerIds } from '../../utils/link-owner'
1011
import { getExpiration } from '../../utils/time'
1112

1213
const D1_CURSOR_PREFIX = 'd1:v1:'
@@ -73,7 +74,7 @@ function statusCondition(status: LinkStatus, now = Math.floor(Date.now() / 1000)
7374
}
7475

7576
function ownerCondition(event: H3Event) {
76-
return eq(links.ownerId, getCurrentLinkOwnerId(event))
77+
return inArray(links.ownerId, getCurrentLinkOwnerIds(event))
7778
}
7879

7980
function exactTagCondition(db: ReturnType<typeof getDatabase>, tag: string | undefined) {

server/utils/admin-auth.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,27 @@ import type { H3Event } from 'h3'
22
import { createError } from 'h3'
33

44
export function assertSiteAdministrator(event: H3Event): void {
5-
if (event.context.authMethod === 'site-token' && event.context.userID === 'root')
5+
if (isSiteAdministrator(event))
66
return
77

88
throw createError({
99
status: 403,
1010
statusText: 'Administrator access required',
1111
})
1212
}
13+
14+
export function isSiteAdministrator(event: H3Event): boolean {
15+
if (event.context.authMethod === 'site-token' && event.context.userID === 'root')
16+
return true
17+
18+
if (event.context.authMethod !== 'oidc-session' || !event.context.userEmail)
19+
return false
20+
21+
const { siteAdminEmails } = useRuntimeConfig(event)
22+
const administrators = String(siteAdminEmails)
23+
.split(',')
24+
.map(email => email.trim().toLowerCase())
25+
.filter(Boolean)
26+
27+
return administrators.includes(event.context.userEmail.trim().toLowerCase())
28+
}

server/utils/link-owner.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,54 @@
11
import type { H3Event } from 'h3'
2+
import { createError } from 'h3'
23

34
export function getCurrentLinkOwnerId(event: H3Event): string {
45
const ownerId = event.context.userID
5-
if (ownerId)
6-
return ownerId
6+
if (!ownerId) {
7+
throw createError({
8+
status: 401,
9+
statusText: 'Unauthorized',
10+
})
11+
}
12+
return ownerId
13+
}
14+
15+
export function getCurrentLinkOwnerIds(event: H3Event): string[] {
16+
const ownerId = getCurrentLinkOwnerId(event)
17+
const aliases = parseOwnerAliases(configString(event, 'linkOwnerAliases', 'NUXT_LINK_OWNER_ALIASES'))
18+
return [...new Set([ownerId, ...(aliases[ownerId] ?? [])])]
19+
}
20+
21+
function parseOwnerAliases(value: string): Record<string, string[]> {
22+
if (!value)
23+
return {}
24+
25+
try {
26+
const parsed = JSON.parse(value) as unknown
27+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
28+
throw new Error('Owner aliases must be an object')
29+
30+
return Object.fromEntries(Object.entries(parsed).map(([ownerId, aliases]) => {
31+
if (!Array.isArray(aliases) || aliases.some(alias => typeof alias !== 'string' || !alias))
32+
throw new Error(`Owner aliases for ${ownerId} must be non-empty strings`)
33+
return [ownerId, aliases]
34+
}))
35+
}
36+
catch (cause) {
37+
throw createError({
38+
status: 500,
39+
statusText: 'NUXT_LINK_OWNER_ALIASES is invalid',
40+
cause,
41+
})
42+
}
43+
}
44+
45+
function configString(event: H3Event, key: string, envKey: string): string {
46+
const config = useRuntimeConfig(event) as unknown as Record<string, unknown>
47+
const configured = config[key]
48+
if (typeof configured === 'string' && configured.trim())
49+
return configured.trim()
750

8-
throw createError({
9-
status: 401,
10-
statusText: 'Unauthorized',
11-
})
51+
const env = event.context.cloudflare?.env as unknown as Record<string, unknown> | undefined
52+
const value = env?.[envKey]
53+
return typeof value === 'string' ? value.trim() : ''
1254
}

tests/unit/admin-auth.spec.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,41 @@
11
import type { H3Event } from 'h3'
2-
import { describe, expect, it } from 'vitest'
3-
import { assertSiteAdministrator } from '../../server/utils/admin-auth'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { assertSiteAdministrator, isSiteAdministrator } from '../../server/utils/admin-auth'
44

55
describe('site administrator authorization', () => {
6-
it('allows only the root site-token identity', () => {
7-
expect(() => assertSiteAdministrator(eventWithIdentity('site-token', 'root'))).not.toThrow()
8-
expect(() => assertSiteAdministrator(eventWithIdentity('oidc-session', 'root'))).toThrowError(
6+
beforeEach(() => {
7+
vi.stubGlobal('useRuntimeConfig', () => ({
8+
siteAdminEmails: ' admin@example.com,OWNER@example.com ',
9+
}))
10+
})
11+
12+
afterEach(() => {
13+
vi.unstubAllGlobals()
14+
})
15+
16+
it('allows the root site-token identity', () => {
17+
expect(isSiteAdministrator(eventWithIdentity('site-token', 'root', 'root@example.com'))).toBe(true)
18+
expect(() => assertSiteAdministrator(eventWithIdentity('site-token', 'root', 'root@example.com'))).not.toThrow()
19+
})
20+
21+
it('allows configured OIDC administrators case-insensitively', () => {
22+
expect(isSiteAdministrator(eventWithIdentity('oidc-session', 'test-user', 'owner@example.com'))).toBe(true)
23+
expect(() => assertSiteAdministrator(eventWithIdentity('oidc-session', 'test-user', 'OWNER@example.com'))).not.toThrow()
24+
})
25+
26+
it('rejects identities outside the administrator allowlist', () => {
27+
expect(isSiteAdministrator(eventWithIdentity('oidc-session', 'root', 'user@example.com'))).toBe(false)
28+
expect(() => assertSiteAdministrator(eventWithIdentity('oidc-session', 'test-user', 'user@example.com'))).toThrowError(
929
expect.objectContaining({ statusCode: 403 }),
1030
)
11-
expect(() => assertSiteAdministrator(eventWithIdentity('oidc-session', 'test-user'))).toThrowError(
31+
expect(() => assertSiteAdministrator(eventWithIdentity('access-user', 'test-user', 'admin@example.com'))).toThrowError(
1232
expect.objectContaining({ statusCode: 403 }),
1333
)
1434
})
1535
})
1636

17-
function eventWithIdentity(authMethod: string, userID: string): H3Event {
37+
function eventWithIdentity(authMethod: string, userID: string, userEmail: string): H3Event {
1838
return {
19-
context: { authMethod, userID },
39+
context: { authMethod, userID, userEmail },
2040
} as H3Event
2141
}

tests/unit/link-owner.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { H3Event } from 'h3'
2+
import { afterEach, describe, expect, it, vi } from 'vitest'
3+
import { getCurrentLinkOwnerId, getCurrentLinkOwnerIds } from '../../server/utils/link-owner'
4+
5+
describe('link owner identities', () => {
6+
afterEach(() => {
7+
vi.unstubAllGlobals()
8+
})
9+
10+
it('uses the authenticated subject as the canonical owner', () => {
11+
vi.stubGlobal('useRuntimeConfig', () => ({ linkOwnerAliases: '' }))
12+
const event = eventWithOwner('current-subject')
13+
14+
expect(getCurrentLinkOwnerId(event)).toBe('current-subject')
15+
expect(getCurrentLinkOwnerIds(event)).toEqual(['current-subject'])
16+
})
17+
18+
it('includes configured prior owner IDs without duplicates', () => {
19+
vi.stubGlobal('useRuntimeConfig', () => ({
20+
linkOwnerAliases: JSON.stringify({
21+
'current-subject': ['previous-subject', 'current-subject', 'previous-subject'],
22+
}),
23+
}))
24+
25+
expect(getCurrentLinkOwnerIds(eventWithOwner('current-subject'))).toEqual([
26+
'current-subject',
27+
'previous-subject',
28+
])
29+
})
30+
31+
it('reads aliases from the Worker environment when runtime config is empty', () => {
32+
vi.stubGlobal('useRuntimeConfig', () => ({ linkOwnerAliases: '' }))
33+
const event = eventWithOwner('current-subject', {
34+
NUXT_LINK_OWNER_ALIASES: '{"current-subject":["previous-subject"]}',
35+
})
36+
37+
expect(getCurrentLinkOwnerIds(event)).toEqual(['current-subject', 'previous-subject'])
38+
})
39+
40+
it('fails closed when alias configuration is invalid', () => {
41+
vi.stubGlobal('useRuntimeConfig', () => ({ linkOwnerAliases: '{"current-subject":"previous-subject"}' }))
42+
43+
expect(() => getCurrentLinkOwnerIds(eventWithOwner('current-subject'))).toThrowError(
44+
expect.objectContaining({ statusCode: 500 }),
45+
)
46+
})
47+
48+
it('requires an authenticated owner', () => {
49+
vi.stubGlobal('useRuntimeConfig', () => ({ linkOwnerAliases: '' }))
50+
51+
expect(() => getCurrentLinkOwnerId(eventWithOwner(''))).toThrowError(
52+
expect.objectContaining({ statusCode: 401 }),
53+
)
54+
})
55+
})
56+
57+
function eventWithOwner(userID: string, env: Record<string, string> = {}): H3Event {
58+
return {
59+
context: {
60+
userID,
61+
cloudflare: { env },
62+
},
63+
} as H3Event
64+
}

0 commit comments

Comments
 (0)