api: API key authentication with rotation and grace period

- Add ApiKey Prisma model (keyHash, expiresAt, revokedAt)
- Protect /api/v1/* routes with Authorization: Bearer header check in hooks
- create rotateApiKey() utility: normal (48h grace) and emergency (immediate)
- Add Super-only /admin/api-keys page for key rotation with one-time reveal
- Fix roleId check by using parent layout's DB-fetched adminUser
- Add API Keys link to admin sidebar
This commit is contained in:
NeilClack
2026-07-08 13:18:19 -05:00
parent 6266be0edf
commit 46b2cdf53d
14 changed files with 1658 additions and 7 deletions
+5
View File
@@ -82,6 +82,11 @@ export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategoryS
*
*/
export type LicenseKey = Prisma.LicenseKeyModel
/**
* Model ApiKey
*
*/
export type ApiKey = Prisma.ApiKeyModel
/**
* Model SubscriptionLevel
*
+5
View File
@@ -106,6 +106,11 @@ export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategoryS
*
*/
export type LicenseKey = Prisma.LicenseKeyModel
/**
* Model ApiKey
*
*/
export type ApiKey = Prisma.ApiKeyModel
/**
* Model SubscriptionLevel
*
File diff suppressed because one or more lines are too long
@@ -397,6 +397,7 @@ export const ModelName = {
ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel'
} as const
@@ -413,7 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "proclamationCategorySubscriptionLevel" | "licenseKey" | "subscriptionLevel"
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "proclamationCategorySubscriptionLevel" | "licenseKey" | "apiKey" | "subscriptionLevel"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1379,6 +1380,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
ApiKey: {
payload: Prisma.$ApiKeyPayload<ExtArgs>
fields: Prisma.ApiKeyFieldRefs
operations: {
findUnique: {
args: Prisma.ApiKeyFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ApiKeyFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
findFirst: {
args: Prisma.ApiKeyFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload> | null
}
findFirstOrThrow: {
args: Prisma.ApiKeyFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
findMany: {
args: Prisma.ApiKeyFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>[]
}
create: {
args: Prisma.ApiKeyCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
createMany: {
args: Prisma.ApiKeyCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ApiKeyCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>[]
}
delete: {
args: Prisma.ApiKeyDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
update: {
args: Prisma.ApiKeyUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
deleteMany: {
args: Prisma.ApiKeyDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ApiKeyUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ApiKeyUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>[]
}
upsert: {
args: Prisma.ApiKeyUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
aggregate: {
args: Prisma.ApiKeyAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateApiKey>
}
groupBy: {
args: Prisma.ApiKeyGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ApiKeyGroupByOutputType>[]
}
count: {
args: Prisma.ApiKeyCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ApiKeyCountAggregateOutputType> | number
}
}
}
SubscriptionLevel: {
payload: Prisma.$SubscriptionLevelPayload<ExtArgs>
fields: Prisma.SubscriptionLevelFieldRefs
@@ -1648,6 +1723,18 @@ export const LicenseKeyScalarFieldEnum = {
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
export const ApiKeyScalarFieldEnum = {
id: 'id',
keyPrefix: 'keyPrefix',
keyHash: 'keyHash',
createdAt: 'createdAt',
expiresAt: 'expiresAt',
revokedAt: 'revokedAt'
} as const
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
export const SubscriptionLevelScalarFieldEnum = {
id: 'id',
name: 'name',
@@ -1872,6 +1959,7 @@ export type GlobalOmitConfig = {
proclamationCategory?: Prisma.ProclamationCategoryOmit
proclamationCategorySubscriptionLevel?: Prisma.ProclamationCategorySubscriptionLevelOmit
licenseKey?: Prisma.LicenseKeyOmit
apiKey?: Prisma.ApiKeyOmit
subscriptionLevel?: Prisma.SubscriptionLevelOmit
}
@@ -64,6 +64,7 @@ export const ModelName = {
ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel'
} as const
@@ -239,6 +240,18 @@ export const LicenseKeyScalarFieldEnum = {
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
export const ApiKeyScalarFieldEnum = {
id: 'id',
keyPrefix: 'keyPrefix',
keyHash: 'keyHash',
createdAt: 'createdAt',
expiresAt: 'expiresAt',
revokedAt: 'revokedAt'
} as const
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
export const SubscriptionLevelScalarFieldEnum = {
id: 'id',
name: 'name',
+1
View File
@@ -21,5 +21,6 @@ export type * from './models/SolfeggioFrequency.ts'
export type * from './models/ProclamationCategory.ts'
export type * from './models/ProclamationCategorySubscriptionLevel.ts'
export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts'
export type * from './models/SubscriptionLevel.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
import { randomBytes, createHash } from 'node:crypto';
import { prisma } from '$lib/db';
export type ApiKeyRecord = {
id: string;
keyPrefix: string;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
};
export type GeneratedKey = {
plaintext: string;
prefix: string;
hash: string;
};
export function generateApiKey(): GeneratedKey {
const raw = randomBytes(32);
const hex = raw.toString('hex');
const plaintext = `sk_${hex}`;
const prefix = plaintext.slice(0, 11);
const hash = createHash('sha256').update(plaintext).digest('hex');
return { plaintext, prefix, hash };
}
export async function validateApiKey(key: string): Promise<ApiKeyRecord | null> {
const hash = createHash('sha256').update(key).digest('hex');
const record = await prisma.apiKey.findUnique({ where: { keyHash: hash } });
if (!record) return null;
if (record.revokedAt && record.revokedAt <= new Date()) return null;
if (record.expiresAt && record.expiresAt <= new Date()) return null;
return record;
}
export async function rotateApiKey(emergency = false): Promise<{
plaintext: string;
prefix: string;
previousExpiresAt: Date | null;
previousRevokedAt: Date | null;
}> {
const now = new Date();
const activeKeys = await prisma.apiKey.findMany({
where: {
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
}
});
let previousExpiresAt: Date | null = null;
let previousRevokedAt: Date | null = null;
if (emergency) {
previousRevokedAt = now;
for (const key of activeKeys) {
await prisma.apiKey.update({
where: { id: key.id },
data: { revokedAt: now }
});
}
previousExpiresAt = null;
} else {
const graceEnd = new Date(now.getTime() + 48 * 60 * 60 * 1000);
previousExpiresAt = graceEnd;
for (const key of activeKeys) {
await prisma.apiKey.update({
where: { id: key.id },
data: { expiresAt: graceEnd }
});
}
previousRevokedAt = null;
}
const { plaintext, prefix, hash } = generateApiKey();
await prisma.apiKey.create({
data: { keyPrefix: prefix, keyHash: hash }
});
return { plaintext, prefix, previousExpiresAt, previousRevokedAt };
}