Merge branch 'cli-improvements-1' with @reviq/utils password hashing
- Use executeBootstrap helper from @reviq/db for CLI bootstrap - Update @reviq/db to use @reviq/utils for PBKDF2-SHA256 password hashing (Cloudflare Workers compatible) - Keep @scure/base for base58 token encoding - Remove redundant password.ts from @reviq/db (import directly from @reviq/utils) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/db-schema": "workspace:*",
|
||||
"@reviq/utils": "workspace:*",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
"kysely": "^0.28.2",
|
||||
|
||||
@@ -61,6 +61,7 @@ function createAuthenticatedContext(
|
||||
userId: number,
|
||||
email: string,
|
||||
): AuthenticatedContext {
|
||||
const now = new Date();
|
||||
return {
|
||||
...createAPIContext(),
|
||||
user: {
|
||||
@@ -73,7 +74,13 @@ function createAuthenticatedContext(
|
||||
session: {
|
||||
id: "1",
|
||||
trustedMode: false,
|
||||
createdAt: new Date(),
|
||||
createdAt: now,
|
||||
},
|
||||
auth: {
|
||||
method: "session",
|
||||
sessionId: "1",
|
||||
expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
|
||||
createdAt: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,33 @@ export interface Session {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* API token authentication info
|
||||
*/
|
||||
export interface ApiTokenAuth {
|
||||
method: "api_token";
|
||||
tokenId: string;
|
||||
tokenName: string;
|
||||
expiresAt: Date;
|
||||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session authentication info
|
||||
*/
|
||||
export interface SessionAuth {
|
||||
method: "session";
|
||||
sessionId: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for authentication method info
|
||||
*/
|
||||
export type AuthInfo = ApiTokenAuth | SessionAuth;
|
||||
|
||||
/**
|
||||
* Authenticated API context for protected handlers
|
||||
*/
|
||||
@@ -52,6 +79,8 @@ export interface AuthenticatedContext extends APIContext {
|
||||
user: SessionUser;
|
||||
/** Current session */
|
||||
session: Session;
|
||||
/** Authentication method and details */
|
||||
auth: AuthInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
AuthInfo,
|
||||
Session,
|
||||
SessionUser,
|
||||
} from "../context.js";
|
||||
@@ -111,24 +112,49 @@ export const createAuthMiddleware = () => {
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
const sessionInfo: Session = session
|
||||
? {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
}
|
||||
: {
|
||||
// For API token auth, create a synthetic session object
|
||||
// We know apiToken exists because userId came from it
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken?.created_at ?? new Date(),
|
||||
};
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
AuthInfo,
|
||||
LoginRequestContext,
|
||||
Session,
|
||||
SessionUser,
|
||||
@@ -109,23 +110,49 @@ export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
const sessionInfo: Session = session
|
||||
? {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
}
|
||||
: {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken?.created_at ?? new Date(),
|
||||
};
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,6 +187,40 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
||||
};
|
||||
});
|
||||
|
||||
const meAuthStatus = os.me.authStatus
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"full_name",
|
||||
"phone_number",
|
||||
"avatar_url",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.where("id", "=", context.user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
fullName: user.full_name,
|
||||
phoneNumber: user.phone_number,
|
||||
avatarUrl: user.avatar_url,
|
||||
emailVerified: user.email_verified_at !== null,
|
||||
needsSetup: user.display_name === null,
|
||||
isSuperuser: user.is_superuser,
|
||||
},
|
||||
auth: context.auth,
|
||||
};
|
||||
});
|
||||
|
||||
const setupProfile = os.me.setupProfile
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
@@ -238,6 +272,7 @@ export const router = os.router({
|
||||
},
|
||||
me: {
|
||||
get: meGet,
|
||||
authStatus: meAuthStatus,
|
||||
setupProfile,
|
||||
updateProfile,
|
||||
delete: meDelete,
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { base58 } from "@scure/base";
|
||||
|
||||
/**
|
||||
* Token prefix for all RevIQ API tokens
|
||||
*/
|
||||
export const TOKEN_PREFIX = "reviq_";
|
||||
|
||||
/**
|
||||
* Hash a token with SHA-256 for storage in database
|
||||
* Never store raw tokens - always hash first
|
||||
@@ -13,6 +20,34 @@ export const hashToken = async (token: string): Promise<string> => {
|
||||
.join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate that a token has the correct format
|
||||
* Returns the raw bytes if valid, null if invalid
|
||||
*/
|
||||
export const parseToken = (token: string): Uint8Array | null => {
|
||||
if (!token.startsWith(TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const encoded = token.slice(TOKEN_PREFIX.length);
|
||||
try {
|
||||
const bytes = base58.decode(encoded);
|
||||
// Expect 32 bytes of entropy
|
||||
if (bytes.length !== 32) {
|
||||
return null;
|
||||
}
|
||||
return bytes;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a token has the valid reviq_ prefix format
|
||||
*/
|
||||
export const isValidTokenFormat = (token: string): boolean => {
|
||||
return parseToken(token) !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a session token (UUID v4)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user