Improve API token format and enhance auth status command
- Change token format to reviq_<base58> prefix instead of raw hex - Add me.authStatus API endpoint for detailed auth information - Enhance CLI `reviq auth status` to show token details from API - Add comprehensive tests for token generation (18 tests) - Extract bootstrap logic to @reviq/db for reusability and testing - Remove default db export; callers must use createDb() directly Token changes: - New format: reviq_<base58-encoded-32-bytes> - Added parseToken() for validation - Added isValidTokenFormat() helper Auth status endpoint returns: - User profile information - Auth method (api_token or session) - Token/session details (name, expiration, last used) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@reviq/api-contract": "workspace:*",
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/db-schema": "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";
|
||||
@@ -125,10 +126,28 @@ export const createAuthMiddleware = () => {
|
||||
createdAt: apiToken?.created_at ?? new Date(),
|
||||
};
|
||||
|
||||
// Build auth info based on authentication method
|
||||
const authInfo: AuthInfo = session
|
||||
? {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
}
|
||||
: {
|
||||
method: "api_token",
|
||||
tokenId: apiToken?.id,
|
||||
tokenName: apiToken?.name,
|
||||
expiresAt: apiToken?.expires_at,
|
||||
lastUsedAt: apiToken?.last_used_at,
|
||||
createdAt: apiToken?.created_at,
|
||||
};
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
AuthInfo,
|
||||
LoginRequestContext,
|
||||
Session,
|
||||
SessionUser,
|
||||
@@ -122,10 +123,28 @@ export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||
createdAt: apiToken?.created_at ?? new Date(),
|
||||
};
|
||||
|
||||
// Build auth info based on authentication method
|
||||
const authInfo: AuthInfo = session
|
||||
? {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
}
|
||||
: {
|
||||
method: "api_token",
|
||||
tokenId: apiToken?.id,
|
||||
tokenName: apiToken?.name,
|
||||
expiresAt: apiToken?.expires_at,
|
||||
lastUsedAt: apiToken?.last_used_at,
|
||||
createdAt: apiToken?.created_at,
|
||||
};
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,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 }) => {
|
||||
@@ -314,6 +348,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