Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
# Required authentication
# Single-user authentication (used only when OIDC is not configured)
# Workers Runtime: set as an encrypted secret under Worker Variables and Secrets.
# Pages: set as an encrypted secret under the unified Variables and Secrets.
NUXT_SITE_TOKEN=replace-with-a-strong-private-token

# OIDC multi-user authentication
# Setting any required OIDC value selects OIDC mode. Set all four required
# values (issuer, client ID, client secret, and session secret) together.
# Register a confidential web client with this exact callback URL:
# https://your-sink.example/api/auth/callback
# Keep the client and session secrets in encrypted runtime secrets.
NUXT_OIDC_ISSUER=
NUXT_OIDC_CLIENT_ID=
NUXT_OIDC_CLIENT_SECRET=
NUXT_OIDC_REDIRECT_URI=
NUXT_OIDC_SESSION_SECRET=
NUXT_OIDC_SESSION_TTL_SECONDS=28800

# Optional OIDC multi-user administration and identity continuity
# Comma-separated OIDC email claims allowed to run site-wide administration.
NUXT_SITE_ADMIN_EMAILS=
# JSON map from a current OIDC subject to prior owner IDs it may continue to access.
# Example: {"current-subject":["previous-subject"]}
NUXT_LINK_OWNER_ALIASES=

# Required deployment configuration
# Workers Build: set in Workers Builds variables.
# Pages: set in the unified Variables and Secrets.
Expand Down
9 changes: 8 additions & 1 deletion app/components/dashboard/Logout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,15 @@ const slots = defineSlots<{
const open = defineModel<boolean>('open', { default: false })
const { authMethod, accessEnabled, clearAuthSession } = useAuthSession()

function logOut() {
async function logOut() {
const method = authMethod.value || (getAuthToken() ? 'site-token' : 'access-user')

if (method === 'oidc-session') {
clearAuthSession()
await signOutFromOidc()
return
}

const shouldLogoutAccess = accessEnabled.value || method === 'access-user' || method === 'access-service'
removeAuthToken()
clearAuthSession()
Expand Down
18 changes: 16 additions & 2 deletions app/components/login/index.vue
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
<script setup lang="ts">
const { data: oidcConfig, status } = useFetch<{ enabled: boolean }>('/api/auth/config')
const loginMode = computed(() => resolveLoginMode(status.value, oidcConfig.value?.enabled))
</script>

<template>
<Card class="w-full max-w-sm">
<CardHeader>
Expand All @@ -6,12 +11,21 @@
{{ $t('login.title') }}
</h1>
</CardTitle>
<CardDescription>
<CardDescription v-if="loginMode === 'single-user'">
{{ $t('login.description') }}
</CardDescription>
</CardHeader>
<CardContent class="grid gap-4">
<LoginForm />
<Skeleton v-if="loginMode === 'loading'" class="h-9 w-full" />
<template v-else-if="loginMode === 'oidc'">
<Button type="button" class="w-full" @click="signInWithOidc()">
{{ $t('login.oidc_submit') }}
</Button>
</template>
<LoginForm v-else-if="loginMode === 'single-user'" />
<Alert v-else variant="destructive" role="alert">
<AlertTitle>{{ $t('login.failed') }}</AlertTitle>
</Alert>
</CardContent>
</Card>
</template>
1 change: 1 addition & 0 deletions app/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export async function useAPI(api: string, options?: APIOptions): Promise<unknown
const { headers, ...fetchOptions } = options ?? {}
const requestOptions: NitroFetchOptions<NitroFetchRequest> = {
...fetchOptions,
credentials: 'same-origin',
headers: {
'Authorization': `Bearer ${getAuthToken() ?? ''}`,
'X-Requested-With': 'XMLHttpRequest',
Expand Down
10 changes: 10 additions & 0 deletions app/utils/login-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type LoginMode = 'loading' | 'oidc' | 'single-user' | 'error'
export type LoginConfigStatus = 'idle' | 'pending' | 'success' | 'error'

export function resolveLoginMode(status: LoginConfigStatus, oidcEnabled?: boolean): LoginMode {
if (status === 'idle' || status === 'pending')
return 'loading'
if (status === 'error')
return 'error'
return oidcEnabled ? 'oidc' : 'single-user'
}
18 changes: 18 additions & 0 deletions app/utils/oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useAPI } from './api'

export function signInWithOidc(returnTo = '/dashboard'): void {
if (!import.meta.client)
return

const safeReturnTo = returnTo.startsWith('/dashboard') && !returnTo.startsWith('//')
? returnTo
: '/dashboard'
window.location.assign(`/api/auth/login?returnTo=${encodeURIComponent(safeReturnTo)}`)
}

export async function signOutFromOidc(): Promise<void> {
await useAPI('/api/auth/logout', {
method: 'POST',
})
window.location.assign('/dashboard/login')
}
2 changes: 1 addition & 1 deletion docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Send your site password in the `Authorization` header:
Authorization: Bearer YOUR_SITE_TOKEN
```

(`Bearer` means “here is the token”.) It must match `NUXT_SITE_TOKEN` exactly (at least 8 characters). With [Cloudflare Access](/configuration/cloudflare-access) enabled, browsers can also authenticate with a verified Access login.
(`Bearer` means “here is the token”.) In single-user mode it must match `NUXT_SITE_TOKEN` exactly (at least 8 characters). In OIDC multi-user mode, protected APIs require the browser's OIDC session and reject the site token. OIDC access and ID tokens remain on the server and are never stored by the browser.

## CORS

Expand Down
2 changes: 2 additions & 0 deletions docs/configuration/cloudflare-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ description: Optional Zero Trust login for the Sink dashboard, while keeping sho

Cloudflare Access is **optional**. Use it when you want people to sign in to the dashboard with your company identity (Google, email OTP, SSO, and so on) instead of only sharing `NUXT_SITE_TOKEN`.

This integration applies to Sink's single-user mode. Configuring OIDC selects the separate multi-user mode, where protected APIs accept only OIDC sessions and ignore both Cloudflare Access identity and the site token.

Short links stay public either way. Access only affects who can open the dashboard and call the API.

## What changes after you enable it
Expand Down
30 changes: 25 additions & 5 deletions docs/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,31 @@ Set this yourself. It is the **dashboard login password** and the **API password
If you leave it empty, Sink may invent a random password at build time that can change on the next deploy.
:::

| Variable | When | Where | Purpose |
| ------------------------ | ---------------- | ------------------------------------ | ----------------------------------------- |
| `NUXT_SITE_TOKEN` | Runtime (secret) | Encrypted secret on Workers or Pages | Login + API password |
| `DEPLOY_D1_DATABASE_ID` | Build | Workers Builds or Pages variables | D1 database ID (from the D1 detail page) |
| `DEPLOY_KV_NAMESPACE_ID` | Build | Workers Builds or Pages variables | KV namespace ID (from the KV detail page) |
| Variable | When | Where | Purpose |
| ------------------------------- | ---------------- | ------------------------------------ | ---------------------------------------------------------- |
| `NUXT_SITE_TOKEN` | Runtime (secret) | Encrypted secret on Workers or Pages | Single-user login + API password |
| `NUXT_OIDC_ISSUER` | Runtime | Worker/Pages variable | Optional OpenID Connect issuer URL |
| `NUXT_OIDC_CLIENT_ID` | Runtime | Worker/Pages variable | Confidential web client ID |
| `NUXT_OIDC_CLIENT_SECRET` | Runtime (secret) | Encrypted secret on Workers or Pages | Confidential web client secret |
| `NUXT_OIDC_REDIRECT_URI` | Runtime | Worker/Pages variable | Exact callback URL ending in `/api/auth/callback` |
| `NUXT_OIDC_SESSION_SECRET` | Runtime (secret) | Encrypted secret on Workers or Pages | Random value of at least 32 characters for signed sessions |
| `NUXT_OIDC_SESSION_TTL_SECONDS` | Runtime | Worker/Pages variable | Local session limit; defaults to `28800` |
| `DEPLOY_D1_DATABASE_ID` | Build | Workers Builds or Pages variables | D1 database ID (from the D1 detail page) |
| `DEPLOY_KV_NAMESPACE_ID` | Build | Workers Builds or Pages variables | KV namespace ID (from the KV detail page) |

### Authentication modes

Sink uses one authentication mode at a time:

- Without OIDC configuration, Sink remains a single-user application. The site token and Cloudflare Access use the shared `root` owner.
- Configuring OIDC enables multi-user mode. Browser and API access then require an OIDC session; the site token and Cloudflare Access no longer authenticate protected APIs.
- Setting only part of the required OIDC configuration fails closed. Configure the issuer, client ID, client secret, and session secret together.

### User ownership

In OIDC multi-user mode, each verified identity gets an isolated set of links, tags, and analytics. Sink uses the provider's stable subject identifier as the owner ID. In single-user mode, links created through the site token or Cloudflare Access and existing links upgraded from an earlier release belong to the shared `root` owner.

Short-link slugs remain globally unique because every public redirect shares the same hostname. A user therefore receives a conflict when another user already owns the requested slug, without gaining access to that link.

## Recommended (analytics)

Expand Down
2 changes: 1 addition & 1 deletion docs/zh-CN/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ description: Sink 的 OpenAPI 文档、身份认证、CORS 与端点索引。
Authorization: Bearer YOUR_SITE_TOKEN
```

(`Bearer` 的意思是「后面是令牌」。)必须与 `NUXT_SITE_TOKEN` 完全一致(至少 8 个字符)。启用 [Cloudflare Access](/zh-CN/configuration/cloudflare-access) 后,浏览器也可以用已验证的 Access 登录访问 API
(`Bearer` 的意思是「后面是令牌」。)在单用户模式下,它必须与 `NUXT_SITE_TOKEN` 完全一致(至少 8 个字符)。在 OIDC 多用户模式下,受保护的 API 必须使用浏览器的 OIDC Session,并拒绝站点令牌。OIDC access token 和 ID token 始终保留在服务端,不会存入浏览器

## CORS

Expand Down
2 changes: 2 additions & 0 deletions docs/zh-CN/configuration/cloudflare-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ description: 为 Sink 仪表盘启用可选的 Zero Trust 登录,同时保持

Cloudflare Access 是**可选**功能。适合希望用公司身份(Google、邮箱 OTP、SSO 等)登录仪表盘,而不是只靠分享 `NUXT_SITE_TOKEN` 的场景。

此集成只适用于 Sink 的单用户模式。配置 OIDC 后会进入独立的多用户模式,此时受保护的 API 只接受 OIDC Session,不再使用 Cloudflare Access 身份或站点令牌。

无论是否启用 Access,短链接都保持公开。Access 只影响谁能打开仪表盘、谁能调用 API。

## 启用后会发生什么
Expand Down
30 changes: 25 additions & 5 deletions docs/zh-CN/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,31 @@ description: Sink 支持的全部环境变量——做什么、填在哪、什
如果留空,Sink 可能在构建时随机生成密码,下次部署可能变化。
:::

| 变量 | 时机 | 放哪里 | 用途 |
| ------------------------ | -------------- | ---------------------------- | ------------------------------ |
| `NUXT_SITE_TOKEN` | 运行时(密钥) | Workers 或 Pages 的加密密钥 | 登录 + API 密码 |
| `DEPLOY_D1_DATABASE_ID` | 构建时 | Workers Builds 或 Pages 变量 | D1 数据库 ID(在 D1 详情页) |
| `DEPLOY_KV_NAMESPACE_ID` | 构建时 | Workers Builds 或 Pages 变量 | KV 命名空间 ID(在 KV 详情页) |
| 变量 | 时机 | 放哪里 | 用途 |
| ------------------------------- | -------------- | ---------------------------- | ------------------------------------------ |
| `NUXT_SITE_TOKEN` | 运行时(密钥) | Workers 或 Pages 的加密密钥 | 单用户登录 + API 密码 |
| `NUXT_OIDC_ISSUER` | 运行时 | Worker 或 Pages 变量 | 可选的 OpenID Connect 签发者 URL |
| `NUXT_OIDC_CLIENT_ID` | 运行时 | Worker 或 Pages 变量 | 机密 Web 客户端 ID |
| `NUXT_OIDC_CLIENT_SECRET` | 运行时(密钥) | Workers 或 Pages 的加密密钥 | 机密 Web 客户端密钥 |
| `NUXT_OIDC_REDIRECT_URI` | 运行时 | Worker 或 Pages 变量 | 以 `/api/auth/callback` 结尾的精确回调 URL |
| `NUXT_OIDC_SESSION_SECRET` | 运行时(密钥) | Workers 或 Pages 的加密密钥 | 至少 32 字符的随机 Session 签名密钥 |
| `NUXT_OIDC_SESSION_TTL_SECONDS` | 运行时 | Worker 或 Pages 变量 | 本地 Session 上限,默认 `28800` |
| `DEPLOY_D1_DATABASE_ID` | 构建时 | Workers Builds 或 Pages 变量 | D1 数据库 ID(在 D1 详情页) |
| `DEPLOY_KV_NAMESPACE_ID` | 构建时 | Workers Builds 或 Pages 变量 | KV 命名空间 ID(在 KV 详情页) |

### 认证模式

Sink 同一时间只使用一种认证模式:

- 未配置 OIDC 时,Sink 保持单用户模式。站点令牌和 Cloudflare Access 共用 `root` owner。
- 配置 OIDC 后启用多用户模式。浏览器和 API 都必须使用 OIDC Session;站点令牌和 Cloudflare Access 不再能认证受保护的 API。
- 只配置部分 OIDC 必填项时会以失败关闭。签发者、客户端 ID、客户端密钥和 Session 密钥必须一起配置完整。

### 用户数据归属

在 OIDC 多用户模式下,每个通过验证的身份都有相互隔离的链接、标签和访问分析数据,Sink 使用身份提供方稳定的 subject 标识作为 owner ID。在单用户模式下,通过站点令牌或 Cloudflare Access 创建的链接,以及从旧版本升级的现有链接,都属于共享的 `root` owner。

由于同一主机名下的公开跳转共用短码命名空间,短码仍然全局唯一。如果其他用户已经占用了指定短码,当前用户只会收到冲突响应,无法访问对方的链接。

## 推荐配置(访问分析)

Expand Down
2 changes: 2 additions & 0 deletions drizzle/0005_cheerful_slipstream.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE `links` ADD `owner_id` text DEFAULT 'root' NOT NULL;--> statement-breakpoint
CREATE INDEX `links_owner_id_created_at_slug_idx` ON `links` (`owner_id`,`created_at`,`slug`);
2 changes: 1 addition & 1 deletion drizzle/meta/0004_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -415,4 +415,4 @@
}
}
}
}
}
Loading