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:
@@ -82,6 +82,11 @@ export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategoryS
|
||||
*
|
||||
*/
|
||||
export type LicenseKey = Prisma.LicenseKeyModel
|
||||
/**
|
||||
* Model ApiKey
|
||||
*
|
||||
*/
|
||||
export type ApiKey = Prisma.ApiKeyModel
|
||||
/**
|
||||
* Model SubscriptionLevel
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user