mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-30 22:21:58 +01:00
feat(auth): API keys for programmatic access (#380)
* feat(auth): API keys for programmatic access Adds the @better-auth/api-key plugin so scripts and CI pipelines can call the existing endpoints with an x-api-key header instead of a session cookie. Keys are owned by a user, hashed at rest, prefixed gm_, never expire unless an expiry is chosen, and are not rate limited. A small guard plugin refuses key management calls that arrive with a key, so a leaked key cannot mint or revoke keys. New API Keys section on the Authentication tab with create, show once, copy and revoke. Migration 0017 adds the api_keys table with the validator fixture. docs/API.md documents the header and the calls automation needs. An e2e spec covers create, use, refuse and revoke over HTTP. bun.nix regenerated for the new package. Closes #314 Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX * fix(e2e): send Origin on cookie-authenticated key management calls Better Auth rejects a cookie-authenticated POST without an Origin header (403 MISSING_OR_NULL_ORIGIN). Browsers always send one, the Playwright request context does not, so the spec sets it on the create and delete calls. Also asserts the guard's 403 code and documents the Origin requirement for scripts that manage keys with a session. Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
This commit is contained in:
1 parent
8d22774b0a
commit
83dd74dd52
22 files changed
+4206
No files matched your search
@@ -497,6 +497,18 @@ Gitea Mirror can also act as an OIDC provider for other applications. Register O
|
||||
- Create service-to-service authentication
|
||||
- Build integrations with your Gitea Mirror instance
|
||||
|
||||
### 5. API Keys (Scripts and CI)
|
||||
Create a personal API key under Settings → Authentication → API Keys and send it in the `x-api-key` header to call the app from scripts, CI pipelines or workflow tools. Keys act as the user who created them, can be given an expiry, and can be revoked at any time.
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://your-domain.com/api/sync/repository \
|
||||
-H "x-api-key: gm_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"owner":"octocat","repo":"hello-world"}'
|
||||
```
|
||||
|
||||
See the [API guide](docs/API.md) for the endpoints to add, list and mirror repositories.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Pull Request Mirroring Implementation
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@astrojs/mdx": "^7.0.3",
|
||||
"@astrojs/node": "^11.0.2",
|
||||
"@astrojs/react": "^6.0.1",
|
||||
"@better-auth/api-key": "1.7.2",
|
||||
"@better-auth/oauth-provider": "1.7.2",
|
||||
"@better-auth/sso": "1.7.2",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
@@ -190,6 +191,8 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@better-auth/api-key": ["@better-auth/api-key@1.7.2", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "better-auth": "^1.7.2", "better-call": "1.4.0" } }, "sha512-jih45wZaQ83lVYdChSnasxb8iax8p7AYetZ/XiImA9Yuag+cqmLBsaLPNvds5aZV18hLpmvzVkuzf7H/k1X88g=="],
|
||||
|
||||
"@better-auth/core": ["@better-auth/core@1.7.2", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg=="],
|
||||
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg=="],
|
||||
|
||||
@@ -209,6 +209,10 @@
|
||||
url = "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz";
|
||||
hash = "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==";
|
||||
};
|
||||
"@better-auth/api-key@1.7.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@better-auth/api-key/-/api-key-1.7.2.tgz";
|
||||
hash = "sha512-jih45wZaQ83lVYdChSnasxb8iax8p7AYetZ/XiImA9Yuag+cqmLBsaLPNvds5aZV18hLpmvzVkuzf7H/k1X88g==";
|
||||
};
|
||||
"@better-auth/core@1.7.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@better-auth/core/-/core-1.7.2.tgz";
|
||||
hash = "sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg==";
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
# API access with API keys
|
||||
|
||||
Gitea Mirror's web UI talks to a set of JSON endpoints under `/api`. With an API key you can call the same endpoints from scripts, CI pipelines and workflow tools such as n8n, without a browser session.
|
||||
|
||||
## Create a key
|
||||
|
||||
1. Sign in and open **Settings → Authentication**.
|
||||
2. In the **API Keys** section click **Create key**, give it a name and pick an expiry (never, 30 days, 90 days or a year).
|
||||
3. Copy the key. It is shown once; afterwards only its first characters are visible in the list.
|
||||
|
||||
Keys start with `gm_`, belong to the user who created them and carry the same rights as that user's session. Revoke a key from the same list at any time. Keys are stored hashed. They are not rate limited, so a runaway script is stopped by revoking its key.
|
||||
|
||||
## Send the key
|
||||
|
||||
Put the key in the `x-api-key` header. Every endpoint that accepts a session cookie accepts the header instead.
|
||||
|
||||
```bash
|
||||
curl -sS "https://mirror.example.com/api/github/repositories" \
|
||||
-H "x-api-key: gm_..."
|
||||
```
|
||||
|
||||
A missing, malformed, expired or revoked key gets `401 Unauthorized`:
|
||||
|
||||
```json
|
||||
{ "success": false, "error": "Unauthorized" }
|
||||
```
|
||||
|
||||
If the app is served under a base path (`BASE_PATH=/mirror`), prefix every URL with it: `/mirror/api/...`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
The calls below cover the automation cases from issue #314. Response bodies show the fields you are most likely to use; the app returns more.
|
||||
|
||||
### List tracked repositories
|
||||
|
||||
`GET /api/github/repositories`
|
||||
|
||||
Despite the name it lists every repository the account tracks, whatever the configured source. Requires a saved configuration, otherwise `404`.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Repositories fetched successfully",
|
||||
"repositories": [
|
||||
{
|
||||
"id": "9b2f...",
|
||||
"name": "hello-world",
|
||||
"fullName": "octocat/hello-world",
|
||||
"owner": "octocat",
|
||||
"organization": null,
|
||||
"status": "mirrored",
|
||||
"mirroredLocation": "github-mirrors/hello-world",
|
||||
"lastMirrored": "2026-09-02T08:00:00.000Z",
|
||||
"errorMessage": null,
|
||||
"destinationOrg": null,
|
||||
"sourceProvider": "github",
|
||||
"isPrivate": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`status` is one of `imported`, `mirroring`, `mirrored`, `failed`, `syncing`, `synced`, `skipped`, `deleted`, `archived`.
|
||||
|
||||
### Add a repository
|
||||
|
||||
`POST /api/sync/repository`
|
||||
|
||||
```json
|
||||
{ "owner": "octocat", "repo": "hello-world" }
|
||||
```
|
||||
|
||||
Optional fields: `destinationOrg` to override the Gitea organization for this repository, `force: true` to refresh the metadata of a repository that is already tracked.
|
||||
|
||||
The repository is looked up on the configured source and added with status `imported`. It is not mirrored until you start a mirror (next call) or the scheduler picks it up.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `200` | Added. `repository.id` is what the mirror call needs. |
|
||||
| `400` | `owner` or `repo` missing. |
|
||||
| `404` | No configuration for this account, or the repository does not exist on the source. |
|
||||
| `409` | Already tracked. Send `force: true` to refresh it instead. |
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Repository added successfully",
|
||||
"repository": { "id": "9b2f...", "fullName": "octocat/hello-world", "status": "imported" }
|
||||
}
|
||||
```
|
||||
|
||||
### Start a mirror
|
||||
|
||||
`POST /api/job/mirror-repo`
|
||||
|
||||
```json
|
||||
{ "repositoryIds": ["9b2f..."] }
|
||||
```
|
||||
|
||||
Starts mirroring in the background and returns straight away. Poll the list endpoint to watch `status` move from `mirroring` to `mirrored` or `failed`.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `200` | Job started. `repositories` lists what was queued. |
|
||||
| `400` | `repositoryIds` missing or empty, or no source token saved. |
|
||||
| `404` | None of the ids belong to this account. |
|
||||
|
||||
`POST /api/job/sync-repo` takes the same body and re-syncs repositories that are already mirrored.
|
||||
|
||||
### Organizations
|
||||
|
||||
`POST /api/sync/organization` adds an organization the same way:
|
||||
|
||||
```json
|
||||
{ "org": "my-org", "role": "member" }
|
||||
```
|
||||
|
||||
`role` is the account's role in that organization (`member`, `admin`, `owner` or `billing_manager`). `409` means it is already tracked; `force: true` refreshes it. Then `POST /api/job/mirror-org` with `{ "organizationIds": ["..."] }` mirrors its repositories.
|
||||
|
||||
## A complete example
|
||||
|
||||
Add a repository and mirror it in one go:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE="https://mirror.example.com"
|
||||
KEY="gm_..."
|
||||
|
||||
added=$(curl -sS -X POST "$BASE/api/sync/repository" \
|
||||
-H "x-api-key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"owner":"octocat","repo":"hello-world"}')
|
||||
|
||||
id=$(echo "$added" | jq -r '.repository.id')
|
||||
|
||||
curl -sS -X POST "$BASE/api/job/mirror-repo" \
|
||||
-H "x-api-key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repositoryIds\":[\"$id\"]}"
|
||||
```
|
||||
|
||||
## Managing keys from the API
|
||||
|
||||
Manage keys through the settings UI or with a browser session. The key endpoints need a session cookie, not a key, so a leaked key cannot mint more keys, and like every cookie-authenticated `POST` to `/api/auth/*` they also need an `Origin` header (browsers send it on their own; a script must add `Origin: https://mirror.example.com`). The key itself is only for the app routes above. The endpoints live under `/api/auth/api-key/`:
|
||||
|
||||
| Call | Body | Result |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/auth/api-key/create` | `{ "name": "ci", "expiresIn": 2592000 }` (`expiresIn` in seconds, omit for no expiry) | The new key, including `key`, shown only here. |
|
||||
| `GET /api/auth/api-key/list` | | `{ "apiKeys": [...], "total": n }`. Each entry has `id`, `name`, `start`, `createdAt`, `lastRequest` and `expiresAt`, never the secret. |
|
||||
| `POST /api/auth/api-key/delete` | `{ "keyId": "..." }` | Revokes the key. |
|
||||
|
||||
## Notes
|
||||
|
||||
- A key can only act on the account that created it. Each account keeps its own source, destination and repositories.
|
||||
- Keys are not rate limited by the app. Source hosts still apply their own limits, and the app tracks GitHub's.
|
||||
- Deleting a user deletes their keys.
|
||||
@@ -17,6 +17,7 @@ This folder contains engineering and operations references for the open-source G
|
||||
### Authentication
|
||||
- **[SSO-OIDC-SETUP.md](./SSO-OIDC-SETUP.md)** – Configure OIDC/SSO providers through the admin UI.
|
||||
- **[SSO_TESTING.md](./SSO_TESTING.md)** – Recipes for local and staging SSO testing (Google, Keycloak, mock providers).
|
||||
- **[API.md](./API.md)** – Create API keys and call the app from scripts, CI pipelines and workflow tools.
|
||||
|
||||
If you are looking for customer-facing playbooks, see the MDX use cases under `www/src/pages/use-cases/`.
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE `api_keys` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`config_id` text DEFAULT 'default' NOT NULL,
|
||||
`name` text,
|
||||
`start` text,
|
||||
`prefix` text,
|
||||
`key` text NOT NULL,
|
||||
`reference_id` text NOT NULL,
|
||||
`refill_interval` integer,
|
||||
`refill_amount` integer,
|
||||
`last_refill_at` integer,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`rate_limit_enabled` integer DEFAULT true NOT NULL,
|
||||
`rate_limit_time_window` integer,
|
||||
`rate_limit_max` integer,
|
||||
`request_count` integer DEFAULT 0 NOT NULL,
|
||||
`remaining` integer,
|
||||
`last_request` integer,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`permissions` text,
|
||||
`metadata` text,
|
||||
FOREIGN KEY (`reference_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_api_keys_reference_id` ON `api_keys` (`reference_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_api_keys_key` ON `api_keys` (`key`);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -120,6 +120,13 @@
|
||||
"when": 1788336988526,
|
||||
"tag": "0016_better_auth_1_7",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1788339447860,
|
||||
"tag": "0017_api_keys",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
"@astrojs/mdx": "^7.0.3",
|
||||
"@astrojs/node": "^11.0.2",
|
||||
"@astrojs/react": "^6.0.1",
|
||||
"@better-auth/api-key": "1.7.2",
|
||||
"@better-auth/oauth-provider": "1.7.2",
|
||||
"@better-auth/sso": "1.7.2",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
|
||||
@@ -503,6 +503,86 @@ function verify0016Migration(db: any) {
|
||||
db.run("INSERT INTO oauth_client_assertions (id, expires_at) VALUES ('jti-16', 1234567890)");
|
||||
}
|
||||
|
||||
function seedPre0017Database(db: any) {
|
||||
// Migrations 0000-0016 have run. Seed a user so the api_keys foreign key
|
||||
// has something to point at, and confirm the table does not exist yet.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-17', 'u17@example.com', 'u17', 'User Seventeen')");
|
||||
const before = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'api_keys'").get();
|
||||
assert(!before, "Expected api_keys to be absent before migration 0017");
|
||||
}
|
||||
|
||||
function verify0017Migration(db: any) {
|
||||
// The table the @better-auth/api-key plugin writes to. Column names are
|
||||
// what the drizzle table in schema.ts declares; the plugin addresses them
|
||||
// through the drizzle field names, so a rename here breaks key creation.
|
||||
const cols = db.query("PRAGMA table_info(api_keys)").all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
}>;
|
||||
const names = cols.map((c) => c.name);
|
||||
for (const expected of [
|
||||
"id",
|
||||
"config_id",
|
||||
"name",
|
||||
"start",
|
||||
"prefix",
|
||||
"key",
|
||||
"reference_id",
|
||||
"refill_interval",
|
||||
"refill_amount",
|
||||
"last_refill_at",
|
||||
"enabled",
|
||||
"rate_limit_enabled",
|
||||
"rate_limit_time_window",
|
||||
"rate_limit_max",
|
||||
"request_count",
|
||||
"remaining",
|
||||
"last_request",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"permissions",
|
||||
"metadata",
|
||||
]) {
|
||||
assert(names.includes(expected), `Expected api_keys.${expected} column to exist`);
|
||||
}
|
||||
const keyCol = cols.find((c) => c.name === "key");
|
||||
assert(keyCol && keyCol.notnull === 1, "Expected api_keys.key to be NOT NULL");
|
||||
const configCol = cols.find((c) => c.name === "config_id");
|
||||
assert(configCol && configCol.dflt_value === "'default'", "Expected api_keys.config_id to default to 'default'");
|
||||
|
||||
const indexes = (db.query("PRAGMA index_list(api_keys)").all() as Array<{ name: string }>).map((i) => i.name);
|
||||
assert(indexes.includes("idx_api_keys_reference_id"), "Expected an index on api_keys.reference_id");
|
||||
assert(indexes.includes("idx_api_keys_key"), "Expected an index on api_keys.key");
|
||||
|
||||
// A row shaped like what the plugin inserts, then the cascade when the
|
||||
// owning user goes away.
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
db.run(
|
||||
"INSERT INTO api_keys (id, name, start, prefix, key, reference_id, enabled, rate_limit_enabled, request_count, created_at, updated_at) " +
|
||||
"VALUES ('key-17', 'ci', 'gm_abcdefg', 'gm_', 'hashed-key', 'u-17', 1, 0, 0, unixepoch(), unixepoch())",
|
||||
);
|
||||
const inserted = db.query("SELECT expires_at, config_id FROM api_keys WHERE id = 'key-17'").get() as {
|
||||
expires_at: number | null;
|
||||
config_id: string;
|
||||
} | null;
|
||||
assert(inserted && inserted.expires_at === null, "Expected a key without expiry to store NULL expires_at");
|
||||
assert(inserted.config_id === "default", "Expected config_id to take the default");
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
db.run("INSERT INTO api_keys (id, key, reference_id, created_at, updated_at) VALUES ('key-orphan', 'h', 'no-such-user', unixepoch(), unixepoch())");
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
assert(rejected, "Expected api_keys.reference_id to require an existing user");
|
||||
|
||||
db.run("DELETE FROM users WHERE id = 'u-17'");
|
||||
const remaining = db.query("SELECT COUNT(*) AS count FROM api_keys").get() as { count: number };
|
||||
assert(remaining.count === 0, "Expected deleting a user to cascade to their API keys");
|
||||
}
|
||||
|
||||
const MIGRATION_0012_TIMESTAMP = 1774062000000;
|
||||
const MIGRATION_0013_TIMESTAMP = 1780377747526;
|
||||
|
||||
@@ -694,6 +774,10 @@ const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
|
||||
seed: seedPre0016Database,
|
||||
verify: verify0016Migration,
|
||||
},
|
||||
"0017_api_keys": {
|
||||
seed: seedPre0017Database,
|
||||
verify: verify0017Migration,
|
||||
},
|
||||
};
|
||||
|
||||
function lintMigrations(selectedMigrations: Migration[]) {
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from 'sonner';
|
||||
import { Check, Copy, Info, Key, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { cn, formatDateShort, formatLastSyncTime } from '@/lib/utils';
|
||||
import {
|
||||
API_KEY_EXPIRY_OPTIONS,
|
||||
API_KEY_HEADER,
|
||||
API_KEY_NAME_MAX_LENGTH,
|
||||
describeKeyExpiry,
|
||||
expiryOptionToSeconds,
|
||||
isKeyExpired,
|
||||
maskKeyStart,
|
||||
normalizeKeyName,
|
||||
type ApiKeyExpiryOption,
|
||||
} from '@/lib/api-keys';
|
||||
|
||||
const DOCS_URL = 'https://github.com/RayLabsHQ/gitea-mirror/blob/main/docs/API.md';
|
||||
|
||||
/** The subset of the plugin's key record the list needs. The secret never comes back. */
|
||||
interface ApiKeyRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
start: string | null;
|
||||
createdAt: string | Date;
|
||||
lastRequest: string | Date | null;
|
||||
expiresAt: string | Date | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function toRows(data: unknown): ApiKeyRow[] {
|
||||
// 1.7 returns { apiKeys, total }; older builds returned the bare array.
|
||||
const list = Array.isArray(data)
|
||||
? data
|
||||
: data && typeof data === 'object' && Array.isArray((data as { apiKeys?: unknown }).apiKeys)
|
||||
? (data as { apiKeys: unknown[] }).apiKeys
|
||||
: [];
|
||||
return list
|
||||
.map((item) => item as ApiKeyRow)
|
||||
.filter((item) => item && typeof item.id === 'string')
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === 'string' && message.trim()) return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Personal API keys for scripts and CI (issue #314). Keys belong to the
|
||||
* signed-in user and carry the same rights as their session. The secret
|
||||
* is shown once, right after creation, and only its first characters are
|
||||
* stored afterwards.
|
||||
*/
|
||||
export function ApiKeysSettings() {
|
||||
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [expiry, setExpiry] = useState<ApiKeyExpiryOption>('never');
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createdKey, setCreatedKey] = useState<{ name: string; secret: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [pendingRevokeId, setPendingRevokeId] = useState<string | null>(null);
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||
|
||||
const loadKeys = useCallback(async () => {
|
||||
try {
|
||||
const result = await authClient.apiKey.list();
|
||||
if (result.error) {
|
||||
setLoadError(errorMessage(result.error, 'Could not load API keys.'));
|
||||
return;
|
||||
}
|
||||
setKeys(toRows(result.data));
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
setLoadError(errorMessage(error, 'Could not load API keys.'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadKeys();
|
||||
}, [loadKeys]);
|
||||
|
||||
const resetDialog = () => {
|
||||
setName('');
|
||||
setExpiry('never');
|
||||
setNameError(null);
|
||||
setCreatedKey(null);
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const handleDialogChange = (open: boolean) => {
|
||||
if (!open && isCreating) return;
|
||||
setDialogOpen(open);
|
||||
if (!open) resetDialog();
|
||||
};
|
||||
|
||||
const handleCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const normalized = normalizeKeyName(name);
|
||||
if (normalized.error) {
|
||||
setNameError(normalized.error);
|
||||
return;
|
||||
}
|
||||
setNameError(null);
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const expiresIn = expiryOptionToSeconds(expiry);
|
||||
const result = await authClient.apiKey.create({
|
||||
name: normalized.value,
|
||||
...(expiresIn !== undefined ? { expiresIn } : {}),
|
||||
});
|
||||
if (result.error || !result.data?.key) {
|
||||
toast.error(errorMessage(result.error, 'Could not create the API key.'));
|
||||
return;
|
||||
}
|
||||
setCreatedKey({ name: normalized.value, secret: result.data.key });
|
||||
toast.success('API key created');
|
||||
await loadKeys();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, 'Could not create the API key.'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!createdKey) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.secret);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Could not copy. Select the key and copy it by hand.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
setRevokingId(id);
|
||||
try {
|
||||
const result = await authClient.apiKey.delete({ keyId: id });
|
||||
if (result.error) {
|
||||
toast.error(errorMessage(result.error, 'Could not revoke the API key.'));
|
||||
return;
|
||||
}
|
||||
setKeys((current) => current.filter((row) => row.id !== id));
|
||||
toast.success('API key revoked');
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, 'Could not revoke the API key.'));
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
setPendingRevokeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Key className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-base font-semibold">API Keys</h3>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={handleDialogChange}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-indigo-500 text-white hover:bg-indigo-600"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create key
|
||||
</Button>
|
||||
<DialogContent className="max-w-lg">
|
||||
{createdKey ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Key created</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is the only time the key for <span className="font-medium text-foreground">{createdKey.name}</span> is
|
||||
shown. Copy it now and store it somewhere safe.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<code
|
||||
className="min-w-0 flex-1 select-all break-all rounded-md border border-border bg-muted px-3 py-2 font-mono text-xs leading-5"
|
||||
data-testid="api-key-secret"
|
||||
>
|
||||
{createdKey.secret}
|
||||
</code>
|
||||
<Button type="button" variant="outline" size="sm" className="h-auto" onClick={handleCopy}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send it in the <code className="font-mono">{API_KEY_HEADER}</code> header. If you lose it, revoke this key and
|
||||
create a new one.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => handleDialogChange(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
The key acts as you: anything your account can do through the app, a script holding this key can do too.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-name">Name</Label>
|
||||
<Input
|
||||
id="api-key-name"
|
||||
value={name}
|
||||
maxLength={API_KEY_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. ci-deploy"
|
||||
autoComplete="off"
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
if (nameError) setNameError(null);
|
||||
}}
|
||||
/>
|
||||
{nameError ? (
|
||||
<p className="text-xs text-destructive">{nameError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Something that tells you where the key is used.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-expiry">Expires</Label>
|
||||
<Select value={expiry} onValueChange={(value) => setExpiry(value as ApiKeyExpiryOption)}>
|
||||
<SelectTrigger id="api-key-expiry" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{API_KEY_EXPIRY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => handleDialogChange(false)} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{isCreating && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Create key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="flex-1 p-6">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="flex flex-col items-start gap-3 text-sm">
|
||||
<p className="text-muted-foreground">{loadError}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void loadKeys()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<Key className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm font-medium">No API keys yet</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Create one to add repositories or start mirrors from scripts, CI pipelines and workflow tools.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
<div className="hidden pb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground sm:grid sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_5.5rem] sm:gap-4">
|
||||
<span>Key</span>
|
||||
<span>Created</span>
|
||||
<span>Last used</span>
|
||||
<span>Expires</span>
|
||||
<span className="sr-only">Actions</span>
|
||||
</div>
|
||||
{keys.map((row) => {
|
||||
const expired = isKeyExpired(row.expiresAt);
|
||||
const inactive = expired || row.enabled === false;
|
||||
const isPending = pendingRevokeId === row.id;
|
||||
const isRevoking = revokingId === row.id;
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
data-testid="api-key-row"
|
||||
className="flex flex-col gap-2 py-3 sm:grid sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_5.5rem] sm:items-center sm:gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className={cn('truncate text-sm font-medium', inactive && 'text-muted-foreground')}>
|
||||
{row.name || 'Unnamed key'}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{maskKeyStart(row.start)}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
<span className="sm:hidden">Created </span>
|
||||
{formatDateShort(row.createdAt) ?? 'Unknown'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
<span className="sm:hidden">Last used </span>
|
||||
{row.lastRequest ? formatLastSyncTime(row.lastRequest) : 'Never used'}
|
||||
</p>
|
||||
<p className={cn('text-xs sm:text-sm', expired ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
<span className="sm:hidden">Expires </span>
|
||||
{describeKeyExpiry(row.expiresAt)}
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingRevokeId(null)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => void handleRevoke(row.id)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Revoke'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Revoke ${row.name || 'key'}`}
|
||||
onClick={() => setPendingRevokeId(row.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
<div className="flex items-center gap-2 px-6 py-3.5 text-xs text-muted-foreground/70">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Send the key in the <code className="font-mono">{API_KEY_HEADER}</code> header.{' '}
|
||||
<a href={DOCS_URL} target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:text-foreground">
|
||||
API docs
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { GiteaConfigForm } from './GiteaConfigForm';
|
||||
import { GitHubMirrorSettings } from './GitHubMirrorSettings';
|
||||
import { AutomationSettings } from './AutomationSettings';
|
||||
import { SSOSettings } from './SSOSettings';
|
||||
import { ApiKeysSettings } from './ApiKeysSettings';
|
||||
import { NotificationSettings } from './NotificationSettings';
|
||||
import type {
|
||||
ConfigApiResponse,
|
||||
@@ -860,6 +861,7 @@ export function ConfigTabs() {
|
||||
|
||||
<TabsContent value="sso" className="space-y-4">
|
||||
<SSOSettings />
|
||||
<ApiKeysSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
API_KEY_EXPIRY_OPTIONS,
|
||||
API_KEY_LENGTH,
|
||||
API_KEY_NAME_MAX_LENGTH,
|
||||
API_KEY_PREFIX,
|
||||
describeKeyExpiry,
|
||||
expiryOptionToSeconds,
|
||||
isKeyExpired,
|
||||
maskKeyStart,
|
||||
normalizeKeyName,
|
||||
} from "./api-keys";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe("api-keys helpers", () => {
|
||||
test("prefix ends with an underscore and the key length matches the plugin floor", () => {
|
||||
expect(API_KEY_PREFIX.endsWith("_")).toBe(true);
|
||||
expect(API_KEY_LENGTH).toBe(64);
|
||||
});
|
||||
|
||||
test("expiry options map to seconds, never maps to undefined", () => {
|
||||
expect(expiryOptionToSeconds("never")).toBeUndefined();
|
||||
expect(expiryOptionToSeconds("30d")).toBe(30 * 24 * 60 * 60);
|
||||
expect(expiryOptionToSeconds("90d")).toBe(90 * 24 * 60 * 60);
|
||||
expect(expiryOptionToSeconds("1y")).toBe(365 * 24 * 60 * 60);
|
||||
});
|
||||
|
||||
test("no option exceeds the plugin's 365 day ceiling", () => {
|
||||
for (const option of API_KEY_EXPIRY_OPTIONS) {
|
||||
if (option.seconds === null) continue;
|
||||
expect(option.seconds / (24 * 60 * 60)).toBeLessThanOrEqual(365);
|
||||
}
|
||||
});
|
||||
|
||||
test("describeKeyExpiry covers never, future, imminent, past and garbage", () => {
|
||||
const now = new Date("2026-09-02T12:00:00Z");
|
||||
expect(describeKeyExpiry(null, now)).toBe("Never");
|
||||
expect(describeKeyExpiry(undefined, now)).toBe("Never");
|
||||
expect(describeKeyExpiry(new Date(now.getTime() + 30 * DAY_MS), now)).toBe("In 30 days");
|
||||
expect(describeKeyExpiry(new Date(now.getTime() + 3600 * 1000), now)).toBe("Within a day");
|
||||
expect(describeKeyExpiry(new Date(now.getTime() - 1000), now)).toBe("Expired");
|
||||
expect(describeKeyExpiry("not a date", now)).toBe("Unknown");
|
||||
// ISO strings are what the list endpoint returns over JSON.
|
||||
expect(describeKeyExpiry(new Date(now.getTime() + 2 * DAY_MS).toISOString(), now)).toBe("In 2 days");
|
||||
});
|
||||
|
||||
test("isKeyExpired treats missing expiry as never expiring", () => {
|
||||
const now = new Date("2026-09-02T12:00:00Z");
|
||||
expect(isKeyExpired(null, now)).toBe(false);
|
||||
expect(isKeyExpired(new Date(now.getTime() + DAY_MS), now)).toBe(false);
|
||||
expect(isKeyExpired(new Date(now.getTime() - DAY_MS), now)).toBe(true);
|
||||
});
|
||||
|
||||
test("maskKeyStart shows the stored start and falls back to the prefix", () => {
|
||||
expect(maskKeyStart("gm_abc1234")).toBe("gm_abc1234••••••••");
|
||||
expect(maskKeyStart(null)).toBe("gm_••••••••");
|
||||
expect(maskKeyStart(" ")).toBe("gm_••••••••");
|
||||
});
|
||||
|
||||
test("normalizeKeyName trims and enforces the plugin's name limit", () => {
|
||||
expect(normalizeKeyName(" ci deploy ")).toEqual({ value: "ci deploy", error: null });
|
||||
expect(normalizeKeyName(" ").error).toContain("name");
|
||||
const long = "x".repeat(API_KEY_NAME_MAX_LENGTH + 1);
|
||||
expect(normalizeKeyName(long).error).toContain(String(API_KEY_NAME_MAX_LENGTH));
|
||||
expect(normalizeKeyName("x".repeat(API_KEY_NAME_MAX_LENGTH)).error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Shared constants and helpers for API keys (issue #314).
|
||||
*
|
||||
* Dependency free so React components can import it. The Better Auth
|
||||
* plugin wiring lives in auth.ts (server) and auth-client.ts (browser).
|
||||
*/
|
||||
|
||||
/** Every key starts with this so it is recognisable in logs and scanners. */
|
||||
export const API_KEY_PREFIX = "gm_";
|
||||
|
||||
/** Request header that carries a key. */
|
||||
export const API_KEY_HEADER = "x-api-key";
|
||||
|
||||
/** Length of the random part. The session hook rejects anything shorter. */
|
||||
export const API_KEY_LENGTH = 64;
|
||||
|
||||
/** Characters kept in the database for display, prefix included. */
|
||||
export const API_KEY_START_LENGTH = 10;
|
||||
|
||||
/** Name length limit enforced by the plugin. */
|
||||
export const API_KEY_NAME_MAX_LENGTH = 32;
|
||||
|
||||
export type ApiKeyExpiryOption = "never" | "30d" | "90d" | "1y";
|
||||
|
||||
const DAY_SECONDS = 60 * 60 * 24;
|
||||
|
||||
export const API_KEY_EXPIRY_OPTIONS: ReadonlyArray<{
|
||||
value: ApiKeyExpiryOption;
|
||||
label: string;
|
||||
seconds: number | null;
|
||||
}> = [
|
||||
{ value: "never", label: "Never", seconds: null },
|
||||
{ value: "30d", label: "30 days", seconds: 30 * DAY_SECONDS },
|
||||
{ value: "90d", label: "90 days", seconds: 90 * DAY_SECONDS },
|
||||
{ value: "1y", label: "1 year", seconds: 365 * DAY_SECONDS },
|
||||
];
|
||||
|
||||
/** Seconds for the create call, or undefined for a key that never expires. */
|
||||
export function expiryOptionToSeconds(option: ApiKeyExpiryOption): number | undefined {
|
||||
const match = API_KEY_EXPIRY_OPTIONS.find((candidate) => candidate.value === option);
|
||||
return match?.seconds ?? undefined;
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | number | null | undefined): Date | null {
|
||||
if (value == null) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/** True once the expiry date has passed. A key without one never expires. */
|
||||
export function isKeyExpired(
|
||||
expiresAt: Date | string | number | null | undefined,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
const date = toDate(expiresAt);
|
||||
return date !== null && date.getTime() <= now.getTime();
|
||||
}
|
||||
|
||||
/** Wording for an expiry date relative to now. */
|
||||
export function describeKeyExpiry(
|
||||
expiresAt: Date | string | number | null | undefined,
|
||||
now: Date = new Date()
|
||||
): string {
|
||||
if (expiresAt == null) return "Never";
|
||||
const date = toDate(expiresAt);
|
||||
if (!date) return "Unknown";
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
if (diffMs <= 0) return "Expired";
|
||||
const days = Math.ceil(diffMs / (DAY_SECONDS * 1000));
|
||||
if (days <= 1) return "Within a day";
|
||||
return `In ${days} days`;
|
||||
}
|
||||
|
||||
/** Display form of a key: the stored starting characters and a mask. */
|
||||
export function maskKeyStart(start: string | null | undefined): string {
|
||||
const shown = start && start.trim() ? start.trim() : API_KEY_PREFIX;
|
||||
return `${shown}${"•".repeat(8)}`;
|
||||
}
|
||||
|
||||
/** Trim a proposed key name and report whether the plugin will accept it. */
|
||||
export function normalizeKeyName(name: string): { value: string; error: string | null } {
|
||||
const value = name.trim();
|
||||
if (!value) return { value, error: "Give the key a name so you can tell it apart later." };
|
||||
if (value.length > API_KEY_NAME_MAX_LENGTH) {
|
||||
return { value, error: `Keep the name to ${API_KEY_NAME_MAX_LENGTH} characters or fewer.` };
|
||||
}
|
||||
return { value, error: null };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { APIError } from "better-auth/api";
|
||||
import {
|
||||
apiKeyGuardPlugin,
|
||||
apiKeyManagementForbidden,
|
||||
isApiKeyManagementRequest,
|
||||
} from "./auth-api-key-guard";
|
||||
|
||||
const withKey = () => new Headers({ "x-api-key": "gm_" + "a".repeat(64) });
|
||||
|
||||
describe("isApiKeyManagementRequest", () => {
|
||||
test("matches key endpoints called with the key header", () => {
|
||||
expect(isApiKeyManagementRequest("/api-key/create", withKey())).toBe(true);
|
||||
expect(isApiKeyManagementRequest("/api-key/list", withKey())).toBe(true);
|
||||
expect(isApiKeyManagementRequest("/api-key/delete", withKey())).toBe(true);
|
||||
expect(isApiKeyManagementRequest("/api-key/update", withKey())).toBe(true);
|
||||
});
|
||||
|
||||
test("lets cookie sessions manage keys", () => {
|
||||
expect(isApiKeyManagementRequest("/api-key/create", new Headers())).toBe(false);
|
||||
expect(isApiKeyManagementRequest("/api-key/list", new Headers({ cookie: "better-auth.session_token=x" }))).toBe(false);
|
||||
});
|
||||
|
||||
test("ignores the header on every other endpoint", () => {
|
||||
expect(isApiKeyManagementRequest("/get-session", withKey())).toBe(false);
|
||||
expect(isApiKeyManagementRequest("/sign-in/email", withKey())).toBe(false);
|
||||
expect(isApiKeyManagementRequest("/api-keys-look-alike", withKey())).toBe(false);
|
||||
});
|
||||
|
||||
test("treats a blank header and missing context as no key", () => {
|
||||
expect(isApiKeyManagementRequest("/api-key/create", new Headers({ "x-api-key": " " }))).toBe(false);
|
||||
expect(isApiKeyManagementRequest(undefined, withKey())).toBe(false);
|
||||
expect(isApiKeyManagementRequest("/api-key/create", undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiKeyGuardPlugin", () => {
|
||||
test("registers one before hook under the api-key-guard id", () => {
|
||||
const plugin = apiKeyGuardPlugin();
|
||||
expect(plugin.id).toBe("api-key-guard");
|
||||
expect(plugin.hooks.before).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("the hook matcher only fires for key management with a key", () => {
|
||||
const [hook] = apiKeyGuardPlugin().hooks.before;
|
||||
expect(hook.matcher({ path: "/api-key/create", headers: withKey() } as any)).toBe(true);
|
||||
expect(hook.matcher({ path: "/api-key/create", headers: new Headers() } as any)).toBe(false);
|
||||
expect(hook.matcher({ path: "/get-session", headers: withKey() } as any)).toBe(false);
|
||||
});
|
||||
|
||||
test("refuses with a 403 and a stable code", () => {
|
||||
const error = apiKeyManagementForbidden();
|
||||
expect(error).toBeInstanceOf(APIError);
|
||||
expect(error.status).toBe("FORBIDDEN");
|
||||
expect(error.body?.code).toBe("API_KEY_CANNOT_MANAGE_KEYS");
|
||||
expect(error.message).toContain("Sign in");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { BetterAuthPlugin } from "better-auth";
|
||||
import { APIError, createAuthMiddleware } from "better-auth/api";
|
||||
import { API_KEY_HEADER } from "./api-keys";
|
||||
|
||||
/**
|
||||
* Keeps API keys from managing API keys.
|
||||
*
|
||||
* With `enableSessionForAPIKeys` a request carrying a valid key gets a
|
||||
* session for every Better Auth endpoint, including the key endpoints
|
||||
* themselves. Left alone, a leaked key could mint a fresh key that
|
||||
* outlives the revocation of the leaked one. This plugin refuses any
|
||||
* `/api-key/*` call that arrives with the key header, so creating,
|
||||
* listing and revoking keys always needs a real sign-in.
|
||||
*/
|
||||
|
||||
/** Endpoint paths (without the auth base path) that manage keys. */
|
||||
export const API_KEY_MANAGEMENT_PATH_PREFIX = "/api-key";
|
||||
|
||||
export function isApiKeyManagementRequest(
|
||||
path: string | undefined,
|
||||
headers: Headers | undefined,
|
||||
): boolean {
|
||||
if (!path || !headers) return false;
|
||||
const managesKeys =
|
||||
path === API_KEY_MANAGEMENT_PATH_PREFIX || path.startsWith(`${API_KEY_MANAGEMENT_PATH_PREFIX}/`);
|
||||
if (!managesKeys) return false;
|
||||
const header = headers.get(API_KEY_HEADER);
|
||||
return typeof header === "string" && header.trim() !== "";
|
||||
}
|
||||
|
||||
export function apiKeyManagementForbidden(): APIError {
|
||||
return new APIError("FORBIDDEN", {
|
||||
message: "API keys cannot create, list or revoke API keys. Sign in to manage keys.",
|
||||
code: "API_KEY_CANNOT_MANAGE_KEYS",
|
||||
});
|
||||
}
|
||||
|
||||
export const apiKeyGuardPlugin = () =>
|
||||
({
|
||||
id: "api-key-guard",
|
||||
hooks: {
|
||||
before: [
|
||||
{
|
||||
matcher: (ctx) => isApiKeyManagementRequest(ctx.path, ctx.headers),
|
||||
handler: createAuthMiddleware(async () => {
|
||||
throw apiKeyManagementForbidden();
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}) satisfies BetterAuthPlugin;
|
||||
@@ -2,6 +2,7 @@ import "@/lib/polyfills/buffer";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import { apiKeyClient } from "@better-auth/api-key/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
@@ -43,6 +44,7 @@ export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
oauthProviderClient(),
|
||||
ssoClient(),
|
||||
apiKeyClient(),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { jwt } from "better-auth/plugins";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { db, users, ssoProviders } from "./db";
|
||||
import { API_KEY_HEADER, API_KEY_LENGTH, API_KEY_PREFIX, API_KEY_START_LENGTH } from "./api-keys";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBase } from "./base-path";
|
||||
import { headerAuthPlugin } from "./auth-header-plugin";
|
||||
import { apiKeyGuardPlugin } from "./auth-api-key-guard";
|
||||
|
||||
/**
|
||||
* Extracts the origins implied by registered SSO identity providers.
|
||||
@@ -368,6 +371,37 @@ export const auth = betterAuth({
|
||||
domainVerification: { enabled: true },
|
||||
}),
|
||||
|
||||
// API keys for automation (issue #314). A request carrying a valid key
|
||||
// in the x-api-key header resolves to the owning user's session inside
|
||||
// getSession, so the middleware and every route guard accept keys
|
||||
// without per-route changes. Keys are hashed at rest; only the first
|
||||
// characters are stored for display. Rows live in the api_keys table.
|
||||
apiKey({
|
||||
enableSessionForAPIKeys: true,
|
||||
apiKeyHeaders: API_KEY_HEADER,
|
||||
defaultPrefix: API_KEY_PREFIX,
|
||||
defaultKeyLength: API_KEY_LENGTH,
|
||||
requireName: true,
|
||||
// The plugin's default is 10 requests per day per key, far too low
|
||||
// for CI or workflow tools. Keys are not rate limited; revoke a key
|
||||
// to stop it.
|
||||
rateLimit: { enabled: false },
|
||||
keyExpiration: {
|
||||
// Never, unless the user picks an expiry when creating the key.
|
||||
defaultExpiresIn: null,
|
||||
minExpiresIn: 1,
|
||||
maxExpiresIn: 365,
|
||||
},
|
||||
startingCharactersConfig: {
|
||||
shouldStore: true,
|
||||
charactersLength: API_KEY_START_LENGTH,
|
||||
},
|
||||
}),
|
||||
|
||||
// Refuses /api-key/* calls that arrive with the key header, so a key
|
||||
// can never create, list or revoke keys. See auth-api-key-guard.ts.
|
||||
apiKeyGuardPlugin(),
|
||||
|
||||
// Header / forward authentication bridge. Exposes
|
||||
// POST /api/auth/sign-in/header so the middleware can mint a real
|
||||
// Better Auth session from trusted upstream headers (Authentik /
|
||||
|
||||
@@ -160,5 +160,6 @@ export {
|
||||
oauthConsents,
|
||||
jwkss,
|
||||
ssoProviders,
|
||||
apikeys,
|
||||
rateLimits
|
||||
} from "./schema";
|
||||
@@ -928,6 +928,46 @@ export const ssoProviders = sqliteTable("sso_providers", {
|
||||
index("idx_sso_providers_issuer").on(table.issuer),
|
||||
]);
|
||||
|
||||
// ===== API Keys (@better-auth/api-key) =====
|
||||
|
||||
// Personal API keys for automation (issue #314). The plugin model is
|
||||
// "apikey"; with usePlural the adapter looks up `apikeys`, the same way
|
||||
// `jwkss` maps the jwks model. `key` holds the hash, `start` the first
|
||||
// characters for display, `referenceId` the owning user.
|
||||
export const apikeys = sqliteTable("api_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").notNull().default("default"),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
referenceId: text("reference_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: integer("last_refill_at", { mode: "timestamp" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
// The plugin writes every row with rate limiting off (see auth.ts); the
|
||||
// column default mirrors the plugin's own schema.
|
||||
rateLimitEnabled: integer("rate_limit_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window"),
|
||||
rateLimitMax: integer("rate_limit_max"),
|
||||
requestCount: integer("request_count").notNull().default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: integer("last_request", { mode: "timestamp" }),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
}, (table) => [
|
||||
index("idx_api_keys_reference_id").on(table.referenceId),
|
||||
index("idx_api_keys_key").on(table.key),
|
||||
]);
|
||||
|
||||
// ===== Rate Limit Tracking =====
|
||||
|
||||
export const rateLimitSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 06 – API keys.
|
||||
*
|
||||
* Creates a personal API key through the Better Auth endpoint, calls the
|
||||
* app with the key in the x-api-key header instead of a session cookie,
|
||||
* then revokes it and checks the key stops working.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { APP_URL, APP_USER_EMAIL, getAppSessionCookies } from "./helpers";
|
||||
|
||||
const KEY_HEADER = "x-api-key";
|
||||
|
||||
/**
|
||||
* Better Auth's CSRF check rejects a cookie-authenticated POST that has no
|
||||
* Origin header (403 MISSING_OR_NULL_ORIGIN). Browsers always send one; the
|
||||
* Playwright request context does not, so the key management calls set it
|
||||
* by hand. App routes and key-authenticated calls do not need it.
|
||||
*/
|
||||
const sessionPostHeaders = (cookies: string) => ({
|
||||
Cookie: cookies,
|
||||
Origin: APP_URL,
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
test.describe("E2E: API keys", () => {
|
||||
let cookies = "";
|
||||
let keyId = "";
|
||||
let secret = "";
|
||||
|
||||
test("Step 1: create a key with the session", async ({ request }) => {
|
||||
cookies = await getAppSessionCookies(request);
|
||||
expect(cookies).toBeTruthy();
|
||||
|
||||
const resp = await request.post(`${APP_URL}/api/auth/api-key/create`, {
|
||||
headers: sessionPostHeaders(cookies),
|
||||
data: { name: "e2e-key" },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(resp.status(), await resp.text()).toBe(200);
|
||||
const body = await resp.json();
|
||||
keyId = body.id;
|
||||
secret = body.key;
|
||||
expect(keyId).toBeTruthy();
|
||||
expect(secret.startsWith("gm_")).toBeTruthy();
|
||||
expect(secret.length).toBeGreaterThan(64);
|
||||
expect(body.expiresAt).toBeNull();
|
||||
expect(body.rateLimitEnabled).toBe(false);
|
||||
console.log(`[ApiKeys] Created key ${body.start}… (${keyId})`);
|
||||
});
|
||||
|
||||
test("Step 2: the key resolves to the user's session", async ({ request }) => {
|
||||
const resp = await request.get(`${APP_URL}/api/auth/get-session`, {
|
||||
headers: { [KEY_HEADER]: secret },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(resp.status(), await resp.text()).toBe(200);
|
||||
const session = await resp.json();
|
||||
expect(session?.user?.email).toBe(APP_USER_EMAIL);
|
||||
});
|
||||
|
||||
test("Step 3: app routes accept the key without a cookie", async ({ request }) => {
|
||||
const resp = await request.get(`${APP_URL}/api/github/repositories`, {
|
||||
headers: { [KEY_HEADER]: secret },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
// 200 once a configuration exists (spec 02 saves one); never a 401.
|
||||
expect(resp.status()).not.toBe(401);
|
||||
expect(resp.status()).toBeLessThan(500);
|
||||
if (resp.status() === 200) {
|
||||
const body = await resp.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(Array.isArray(body.repositories)).toBeTruthy();
|
||||
console.log(`[ApiKeys] Listed ${body.repositories.length} repositories with the key`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Step 4: missing, malformed and unknown keys are rejected", async ({ request }) => {
|
||||
const noKey = await request.get(`${APP_URL}/api/github/repositories`, {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(noKey.status()).toBe(401);
|
||||
|
||||
const shortKey = await request.get(`${APP_URL}/api/github/repositories`, {
|
||||
headers: { [KEY_HEADER]: "gm_not-a-real-key" },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(shortKey.status()).toBe(401);
|
||||
|
||||
const unknownKey = await request.get(`${APP_URL}/api/github/repositories`, {
|
||||
headers: { [KEY_HEADER]: `gm_${"a".repeat(64)}` },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(unknownKey.status()).toBe(401);
|
||||
});
|
||||
|
||||
test("Step 4b: a key cannot create, list or revoke keys", async ({ request }) => {
|
||||
// The api-key-guard plugin (src/lib/auth-api-key-guard.ts) answers 403
|
||||
// with code API_KEY_CANNOT_MANAGE_KEYS before the plugin's own session
|
||||
// check runs, so these are 403 rather than 401.
|
||||
const create = await request.post(`${APP_URL}/api/auth/api-key/create`, {
|
||||
headers: { [KEY_HEADER]: secret, "Content-Type": "application/json" },
|
||||
data: { name: "minted-by-a-key" },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(create.status(), await create.text()).toBe(403);
|
||||
expect((await create.json()).code).toBe("API_KEY_CANNOT_MANAGE_KEYS");
|
||||
|
||||
const list = await request.get(`${APP_URL}/api/auth/api-key/list`, {
|
||||
headers: { [KEY_HEADER]: secret },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(list.status()).toBe(403);
|
||||
|
||||
const del = await request.post(`${APP_URL}/api/auth/api-key/delete`, {
|
||||
headers: { [KEY_HEADER]: secret, "Content-Type": "application/json" },
|
||||
data: { keyId },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(del.status()).toBe(403);
|
||||
});
|
||||
|
||||
test("Step 5: the list shows the key start and usage, never the secret", async ({ request }) => {
|
||||
const resp = await request.get(`${APP_URL}/api/auth/api-key/list`, {
|
||||
headers: { Cookie: cookies },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(resp.status(), await resp.text()).toBe(200);
|
||||
const body = (await resp.json()) as { apiKeys: Array<Record<string, unknown>>; total: number };
|
||||
expect(Array.isArray(body.apiKeys)).toBeTruthy();
|
||||
expect(body.total).toBeGreaterThanOrEqual(1);
|
||||
const row = body.apiKeys.find((candidate) => candidate.id === keyId);
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.key).toBeUndefined();
|
||||
expect(typeof row!.start).toBe("string");
|
||||
expect(secret.startsWith(row!.start as string)).toBeTruthy();
|
||||
expect(row!.lastRequest).toBeTruthy();
|
||||
expect(row!.name).toBe("e2e-key");
|
||||
});
|
||||
|
||||
test("Step 6: revoking the key stops it working", async ({ request }) => {
|
||||
const del = await request.post(`${APP_URL}/api/auth/api-key/delete`, {
|
||||
headers: sessionPostHeaders(cookies),
|
||||
data: { keyId },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(del.status(), await del.text()).toBe(200);
|
||||
|
||||
const resp = await request.get(`${APP_URL}/api/github/repositories`, {
|
||||
headers: { [KEY_HEADER]: secret },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(resp.status()).toBe(401);
|
||||
|
||||
const session = await request.get(`${APP_URL}/api/auth/get-session`, {
|
||||
headers: { [KEY_HEADER]: secret },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(session.status()).not.toBe(200);
|
||||
console.log("[ApiKeys] Revoked key is rejected");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* 03-backup.spec.ts – backup config toggling
|
||||
* 04-force-push.spec.ts – force-push simulation & backup verification
|
||||
* 05-sync-verification.spec.ts – dynamic repos, content integrity, reset
|
||||
* 06-api-keys.spec.ts – API key creation, header auth, revocation
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
|
||||
Reference in new issue
Block a user