feat: implement account settings and API tokens management

- Added SettingsComponent for user profile management, including display name and password change functionality.
- Introduced TokensComponent for managing API tokens, including creation and revocation.
- Created LayoutComponent for consistent application layout with navigation and user information.
- Established main application structure in index.html and main.ts.
- Integrated Tailwind CSS for styling and responsive design.
- Configured TypeScript settings for strict type checking and module resolution.
This commit is contained in:
2025-11-27 22:15:38 +00:00
parent a6c6ea1393
commit ab88ac896f
71 changed files with 9446 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
/**
* AuditService - Centralized audit logging
*/
import type { TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts';
import { AuditLog } from '../models/index.ts';
export interface IAuditContext {
actorId?: string;
actorType?: 'user' | 'api_token' | 'system' | 'anonymous';
actorTokenId?: string;
actorIp?: string;
actorUserAgent?: string;
organizationId?: string;
repositoryId?: string;
}
export class AuditService {
private context: IAuditContext;
constructor(context: IAuditContext = {}) {
this.context = context;
}
/**
* Create a new audit service with context
*/
public static withContext(context: IAuditContext): AuditService {
return new AuditService(context);
}
/**
* Log an audit event
*/
public async log(
action: TAuditAction,
resourceType: TAuditResourceType,
options: {
resourceId?: string;
resourceName?: string;
organizationId?: string;
repositoryId?: string;
metadata?: Record<string, unknown>;
success?: boolean;
errorCode?: string;
errorMessage?: string;
durationMs?: number;
} = {}
): Promise<AuditLog> {
return await AuditLog.log({
actorId: this.context.actorId,
actorType: this.context.actorType,
actorTokenId: this.context.actorTokenId,
actorIp: this.context.actorIp,
actorUserAgent: this.context.actorUserAgent,
action,
resourceType,
resourceId: options.resourceId,
resourceName: options.resourceName,
organizationId: options.organizationId || this.context.organizationId,
repositoryId: options.repositoryId || this.context.repositoryId,
metadata: options.metadata,
success: options.success,
errorCode: options.errorCode,
errorMessage: options.errorMessage,
durationMs: options.durationMs,
});
}
/**
* Log a successful action
*/
public async logSuccess(
action: TAuditAction,
resourceType: TAuditResourceType,
resourceId?: string,
resourceName?: string,
metadata?: Record<string, unknown>
): Promise<AuditLog> {
return await this.log(action, resourceType, {
resourceId,
resourceName,
metadata,
success: true,
});
}
/**
* Log a failed action
*/
public async logFailure(
action: TAuditAction,
resourceType: TAuditResourceType,
errorCode: string,
errorMessage: string,
resourceId?: string,
metadata?: Record<string, unknown>
): Promise<AuditLog> {
return await this.log(action, resourceType, {
resourceId,
metadata,
success: false,
errorCode,
errorMessage,
});
}
// Convenience methods for common actions
public async logUserLogin(userId: string, success: boolean, errorMessage?: string): Promise<AuditLog> {
if (success) {
return await this.logSuccess('USER_LOGIN', 'user', userId);
}
return await this.logFailure('USER_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId);
}
public async logUserLogout(userId: string): Promise<AuditLog> {
return await this.logSuccess('USER_LOGOUT', 'user', userId);
}
public async logTokenCreated(tokenId: string, tokenName: string): Promise<AuditLog> {
return await this.logSuccess('TOKEN_CREATED', 'api_token', tokenId, tokenName);
}
public async logTokenRevoked(tokenId: string, tokenName: string): Promise<AuditLog> {
return await this.logSuccess('TOKEN_REVOKED', 'api_token', tokenId, tokenName);
}
public async logPackagePublished(
packageId: string,
packageName: string,
version: string,
organizationId: string,
repositoryId: string
): Promise<AuditLog> {
return await this.log('PACKAGE_PUBLISHED', 'package', {
resourceId: packageId,
resourceName: packageName,
organizationId,
repositoryId,
metadata: { version },
success: true,
});
}
public async logPackageDownloaded(
packageId: string,
packageName: string,
version: string,
organizationId: string,
repositoryId: string
): Promise<AuditLog> {
return await this.log('PACKAGE_DOWNLOADED', 'package', {
resourceId: packageId,
resourceName: packageName,
organizationId,
repositoryId,
metadata: { version },
success: true,
});
}
public async logOrganizationCreated(orgId: string, orgName: string): Promise<AuditLog> {
return await this.logSuccess('ORGANIZATION_CREATED', 'organization', orgId, orgName);
}
public async logRepositoryCreated(
repoId: string,
repoName: string,
organizationId: string
): Promise<AuditLog> {
return await this.log('REPOSITORY_CREATED', 'repository', {
resourceId: repoId,
resourceName: repoName,
organizationId,
success: true,
});
}
public async logPermissionChanged(
resourceType: TAuditResourceType,
resourceId: string,
targetUserId: string,
oldRole: string | null,
newRole: string | null
): Promise<AuditLog> {
return await this.log('PERMISSION_CHANGED', resourceType, {
resourceId,
metadata: {
targetUserId,
oldRole,
newRole,
},
success: true,
});
}
}

405
ts/services/auth.service.ts Normal file
View File

@@ -0,0 +1,405 @@
/**
* AuthService - JWT-based authentication for UI sessions
*/
import * as plugins from '../plugins.ts';
import { User, Session } from '../models/index.ts';
import { AuditService } from './audit.service.ts';
export interface IJwtPayload {
sub: string; // User ID
email: string;
sessionId: string;
type: 'access' | 'refresh';
iat: number;
exp: number;
}
export interface IAuthResult {
success: boolean;
user?: User;
accessToken?: string;
refreshToken?: string;
sessionId?: string;
errorCode?: string;
errorMessage?: string;
}
export interface IAuthConfig {
jwtSecret: string;
accessTokenExpiresIn: number; // seconds (default: 15 minutes)
refreshTokenExpiresIn: number; // seconds (default: 7 days)
issuer: string;
}
export class AuthService {
private config: IAuthConfig;
private auditService: AuditService;
constructor(config: Partial<IAuthConfig> = {}) {
this.config = {
jwtSecret: config.jwtSecret || Deno.env.get('JWT_SECRET') || 'change-me-in-production',
accessTokenExpiresIn: config.accessTokenExpiresIn || 15 * 60, // 15 minutes
refreshTokenExpiresIn: config.refreshTokenExpiresIn || 7 * 24 * 60 * 60, // 7 days
issuer: config.issuer || 'stack.gallery',
};
this.auditService = new AuditService({ actorType: 'system' });
}
/**
* Login with email and password
*/
public async login(
email: string,
password: string,
options: { userAgent?: string; ipAddress?: string } = {}
): Promise<IAuthResult> {
const auditContext = AuditService.withContext({
actorIp: options.ipAddress,
actorUserAgent: options.userAgent,
actorType: 'anonymous',
});
// Find user by email
const user = await User.findByEmail(email);
if (!user) {
await auditContext.logUserLogin('', false, 'User not found');
return {
success: false,
errorCode: 'INVALID_CREDENTIALS',
errorMessage: 'Invalid email or password',
};
}
// Verify password
const isValid = await user.verifyPassword(password);
if (!isValid) {
await auditContext.logUserLogin(user.id, false, 'Invalid password');
return {
success: false,
errorCode: 'INVALID_CREDENTIALS',
errorMessage: 'Invalid email or password',
};
}
// Check if user is active
if (!user.isActive) {
await auditContext.logUserLogin(user.id, false, 'Account inactive');
return {
success: false,
errorCode: 'ACCOUNT_INACTIVE',
errorMessage: 'Account is inactive',
};
}
// Create session
const session = await Session.createSession({
userId: user.id,
userAgent: options.userAgent || '',
ipAddress: options.ipAddress || '',
});
// Generate tokens
const accessToken = await this.generateAccessToken(user, session.id);
const refreshToken = await this.generateRefreshToken(user, session.id);
// Update user last login
user.lastLoginAt = new Date();
await user.save();
// Audit log
await AuditService.withContext({
actorId: user.id,
actorType: 'user',
actorIp: options.ipAddress,
actorUserAgent: options.userAgent,
}).logUserLogin(user.id, true);
return {
success: true,
user,
accessToken,
refreshToken,
sessionId: session.id,
};
}
/**
* Refresh access token using refresh token
*/
public async refresh(refreshToken: string): Promise<IAuthResult> {
// Verify refresh token
const payload = await this.verifyToken(refreshToken);
if (!payload) {
return {
success: false,
errorCode: 'INVALID_TOKEN',
errorMessage: 'Invalid refresh token',
};
}
if (payload.type !== 'refresh') {
return {
success: false,
errorCode: 'INVALID_TOKEN_TYPE',
errorMessage: 'Not a refresh token',
};
}
// Validate session
const session = await Session.findValidSession(payload.sessionId);
if (!session) {
return {
success: false,
errorCode: 'SESSION_INVALID',
errorMessage: 'Session is invalid or expired',
};
}
// Get user
const user = await User.findById(payload.sub);
if (!user || !user.isActive) {
return {
success: false,
errorCode: 'USER_INVALID',
errorMessage: 'User not found or inactive',
};
}
// Update session activity
await session.touchActivity();
// Generate new access token
const accessToken = await this.generateAccessToken(user, session.id);
return {
success: true,
user,
accessToken,
sessionId: session.id,
};
}
/**
* Logout - invalidate session
*/
public async logout(
sessionId: string,
options: { userId?: string; ipAddress?: string } = {}
): Promise<boolean> {
const session = await Session.findValidSession(sessionId);
if (!session) return false;
await session.invalidate('logout');
if (options.userId) {
await AuditService.withContext({
actorId: options.userId,
actorType: 'user',
actorIp: options.ipAddress,
}).logUserLogout(options.userId);
}
return true;
}
/**
* Logout all sessions for a user
*/
public async logoutAll(
userId: string,
options: { ipAddress?: string } = {}
): Promise<number> {
const count = await Session.invalidateAllUserSessions(userId, 'logout_all');
await AuditService.withContext({
actorId: userId,
actorType: 'user',
actorIp: options.ipAddress,
}).log('USER_LOGOUT', 'user', {
resourceId: userId,
metadata: { sessionsInvalidated: count },
success: true,
});
return count;
}
/**
* Validate access token and return user
*/
public async validateAccessToken(accessToken: string): Promise<{ user: User; sessionId: string } | null> {
const payload = await this.verifyToken(accessToken);
if (!payload || payload.type !== 'access') return null;
// Validate session is still valid
const session = await Session.findValidSession(payload.sessionId);
if (!session) return null;
const user = await User.findById(payload.sub);
if (!user || !user.isActive) return null;
return { user, sessionId: payload.sessionId };
}
/**
* Generate access token
*/
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload: IJwtPayload = {
sub: user.id,
email: user.email,
sessionId,
type: 'access',
iat: now,
exp: now + this.config.accessTokenExpiresIn,
};
return await this.signToken(payload);
}
/**
* Generate refresh token
*/
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload: IJwtPayload = {
sub: user.id,
email: user.email,
sessionId,
type: 'refresh',
iat: now,
exp: now + this.config.refreshTokenExpiresIn,
};
return await this.signToken(payload);
}
/**
* Sign a JWT token
*/
private async signToken(payload: IJwtPayload): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
const data = `${encodedHeader}.${encodedPayload}`;
const signature = await this.hmacSign(data);
return `${data}.${signature}`;
}
/**
* Verify and decode a JWT token
*/
private async verifyToken(token: string): Promise<IJwtPayload | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [encodedHeader, encodedPayload, signature] = parts;
const data = `${encodedHeader}.${encodedPayload}`;
// Verify signature
const expectedSignature = await this.hmacSign(data);
if (signature !== expectedSignature) return null;
// Decode payload
const payload: IJwtPayload = JSON.parse(this.base64UrlDecode(encodedPayload));
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
return payload;
} catch {
return null;
}
}
/**
* HMAC-SHA256 sign
*/
private async hmacSign(data: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(this.config.jwtSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
return this.base64UrlEncode(String.fromCharCode(...new Uint8Array(signature)));
}
/**
* Base64 URL encode
*/
private base64UrlEncode(str: string): string {
const base64 = btoa(str);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
/**
* Base64 URL decode
*/
private base64UrlDecode(str: string): string {
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) {
base64 += '=';
}
return atob(base64);
}
/**
* Hash a password using bcrypt-like approach with Web Crypto
* Note: In production, use a proper bcrypt library
*/
public static async hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const saltHex = Array.from(salt)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const encoder = new TextEncoder();
const data = encoder.encode(saltHex + password);
// Multiple rounds of hashing for security
let hash = data;
for (let i = 0; i < 10000; i++) {
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
}
const hashHex = Array.from(hash)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return `${saltHex}:${hashHex}`;
}
/**
* Verify a password against a hash
*/
public static async verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [saltHex, expectedHash] = storedHash.split(':');
if (!saltHex || !expectedHash) return false;
const encoder = new TextEncoder();
const data = encoder.encode(saltHex + password);
let hash = data;
for (let i = 0; i < 10000; i++) {
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
}
const hashHex = Array.from(hash)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return hashHex === expectedHash;
}
}

22
ts/services/index.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Service exports
*/
export { AuditService, type IAuditContext } from './audit.service.ts';
export {
TokenService,
type ICreateTokenOptions,
type ITokenValidationResult,
} from './token.service.ts';
export {
PermissionService,
type TAction,
type IPermissionContext,
type IResolvedPermissions,
} from './permission.service.ts';
export {
AuthService,
type IJwtPayload,
type IAuthResult,
type IAuthConfig,
} from './auth.service.ts';

View File

@@ -0,0 +1,307 @@
/**
* PermissionService - RBAC resolution across org → team → repo hierarchy
*/
import type {
TOrganizationRole,
TTeamRole,
TRepositoryRole,
TRegistryProtocol,
} from '../interfaces/auth.interfaces.ts';
import {
User,
Organization,
OrganizationMember,
Team,
TeamMember,
Repository,
RepositoryPermission,
} from '../models/index.ts';
export type TAction = 'read' | 'write' | 'delete' | 'admin';
export interface IPermissionContext {
userId: string;
organizationId?: string;
repositoryId?: string;
protocol?: TRegistryProtocol;
}
export interface IResolvedPermissions {
canRead: boolean;
canWrite: boolean;
canDelete: boolean;
canAdmin: boolean;
effectiveRole: TRepositoryRole | null;
organizationRole: TOrganizationRole | null;
teamRoles: Array<{ teamId: string; role: TTeamRole }>;
repositoryRole: TRepositoryRole | null;
}
export class PermissionService {
/**
* Resolve all permissions for a user in a specific context
*/
public async resolvePermissions(context: IPermissionContext): Promise<IResolvedPermissions> {
const result: IResolvedPermissions = {
canRead: false,
canWrite: false,
canDelete: false,
canAdmin: false,
effectiveRole: null,
organizationRole: null,
teamRoles: [],
repositoryRole: null,
};
// Get user
const user = await User.findById(context.userId);
if (!user || !user.isActive) return result;
// System admins have full access
if (user.isSystemAdmin) {
result.canRead = true;
result.canWrite = true;
result.canDelete = true;
result.canAdmin = true;
result.effectiveRole = 'admin';
return result;
}
if (!context.organizationId) return result;
// Get organization membership
const orgMember = await OrganizationMember.findMembership(context.organizationId, context.userId);
if (orgMember) {
result.organizationRole = orgMember.role;
// Organization owners have full access to everything in the org
if (orgMember.role === 'owner') {
result.canRead = true;
result.canWrite = true;
result.canDelete = true;
result.canAdmin = true;
result.effectiveRole = 'admin';
return result;
}
// Organization admins have admin access to all repos
if (orgMember.role === 'admin') {
result.canRead = true;
result.canWrite = true;
result.canDelete = true;
result.canAdmin = true;
result.effectiveRole = 'admin';
return result;
}
}
// If no repository specified, check org-level permissions
if (!context.repositoryId) {
if (orgMember) {
result.canRead = true; // Members can read org info
result.effectiveRole = 'reader';
}
return result;
}
// Get repository
const repository = await Repository.findById(context.repositoryId);
if (!repository) return result;
// Check if repository is public
if (repository.isPublic) {
result.canRead = true;
}
// Get team memberships that grant access to this repository
if (orgMember) {
const teams = await Team.getOrgTeams(context.organizationId);
for (const team of teams) {
const teamMember = await TeamMember.findMembership(team.id, context.userId);
if (teamMember) {
result.teamRoles.push({ teamId: team.id, role: teamMember.role });
// Check if team has access to this repository
if (team.repositoryIds.includes(context.repositoryId)) {
// Team maintainers get maintainer access to repos
if (teamMember.role === 'maintainer') {
this.applyRole(result, 'maintainer');
} else {
// Team members get developer access
this.applyRole(result, 'developer');
}
}
}
}
}
// Get direct repository permission (highest priority)
const repoPerm = await RepositoryPermission.findPermission(context.repositoryId, context.userId);
if (repoPerm) {
result.repositoryRole = repoPerm.role;
this.applyRole(result, repoPerm.role);
}
return result;
}
/**
* Check if user can perform a specific action
*/
public async checkPermission(
context: IPermissionContext,
action: TAction
): Promise<boolean> {
const permissions = await this.resolvePermissions(context);
switch (action) {
case 'read':
return permissions.canRead;
case 'write':
return permissions.canWrite;
case 'delete':
return permissions.canDelete;
case 'admin':
return permissions.canAdmin;
default:
return false;
}
}
/**
* Check if user can access a package
*/
public async canAccessPackage(
userId: string,
organizationId: string,
repositoryId: string,
action: 'read' | 'write' | 'delete'
): Promise<boolean> {
return await this.checkPermission(
{ userId, organizationId, repositoryId },
action
);
}
/**
* Check if user can manage organization
*/
public async canManageOrganization(userId: string, organizationId: string): Promise<boolean> {
const user = await User.findById(userId);
if (!user || !user.isActive) return false;
if (user.isSystemAdmin) return true;
const orgMember = await OrganizationMember.findMembership(organizationId, userId);
return orgMember?.role === 'owner' || orgMember?.role === 'admin';
}
/**
* Check if user can manage repository
*/
public async canManageRepository(
userId: string,
organizationId: string,
repositoryId: string
): Promise<boolean> {
const permissions = await this.resolvePermissions({
userId,
organizationId,
repositoryId,
});
return permissions.canAdmin;
}
/**
* Get all repositories a user can access in an organization
*/
public async getAccessibleRepositories(
userId: string,
organizationId: string
): Promise<Repository[]> {
const user = await User.findById(userId);
if (!user || !user.isActive) return [];
// System admins and org owners/admins can access all repos
if (user.isSystemAdmin) {
return await Repository.getOrgRepositories(organizationId);
}
const orgMember = await OrganizationMember.findMembership(organizationId, userId);
if (orgMember?.role === 'owner' || orgMember?.role === 'admin') {
return await Repository.getOrgRepositories(organizationId);
}
const allRepos = await Repository.getOrgRepositories(organizationId);
const accessibleRepos: Repository[] = [];
for (const repo of allRepos) {
// Public repos are always accessible
if (repo.isPublic) {
accessibleRepos.push(repo);
continue;
}
// Check direct permission
const directPerm = await RepositoryPermission.findPermission(repo.id, userId);
if (directPerm) {
accessibleRepos.push(repo);
continue;
}
// Check team access
const teams = await Team.getOrgTeams(organizationId);
for (const team of teams) {
if (team.repositoryIds.includes(repo.id)) {
const teamMember = await TeamMember.findMembership(team.id, userId);
if (teamMember) {
accessibleRepos.push(repo);
break;
}
}
}
}
return accessibleRepos;
}
/**
* Apply a role's permissions to the result
*/
private applyRole(result: IResolvedPermissions, role: TRepositoryRole): void {
const roleHierarchy: Record<TRepositoryRole, number> = {
reader: 1,
developer: 2,
maintainer: 3,
admin: 4,
};
const currentLevel = result.effectiveRole ? roleHierarchy[result.effectiveRole] : 0;
const newLevel = roleHierarchy[role];
if (newLevel > currentLevel) {
result.effectiveRole = role;
}
switch (role) {
case 'admin':
result.canRead = true;
result.canWrite = true;
result.canDelete = true;
result.canAdmin = true;
break;
case 'maintainer':
result.canRead = true;
result.canWrite = true;
result.canDelete = true;
break;
case 'developer':
result.canRead = true;
result.canWrite = true;
break;
case 'reader':
result.canRead = true;
break;
}
}
}

View File

@@ -0,0 +1,209 @@
/**
* TokenService - API token management with secure hashing
*/
import * as plugins from '../plugins.ts';
import type { ITokenScope, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
import { ApiToken, User } from '../models/index.ts';
import { AuditService } from './audit.service.ts';
export interface ICreateTokenOptions {
userId: string;
name: string;
protocols: TRegistryProtocol[];
scopes: ITokenScope[];
expiresInDays?: number;
createdIp?: string;
}
export interface ITokenValidationResult {
valid: boolean;
token?: ApiToken;
user?: User;
errorCode?: string;
errorMessage?: string;
}
export class TokenService {
private auditService: AuditService;
constructor(auditService?: AuditService) {
this.auditService = auditService || new AuditService({ actorType: 'system' });
}
/**
* Generate a new API token
* Returns the raw token (only shown once) and the saved token record
*/
public async createToken(options: ICreateTokenOptions): Promise<{ rawToken: string; token: ApiToken }> {
// Generate secure random token: srg_{64 hex chars}
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
const hexToken = Array.from(randomBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const rawToken = `srg_${hexToken}`;
// Hash the token for storage
const tokenHash = await this.hashToken(rawToken);
const tokenPrefix = rawToken.substring(0, 12); // "srg_" + first 8 hex chars
// Create token record
const token = new ApiToken();
token.id = await ApiToken.getNewId();
token.userId = options.userId;
token.name = options.name;
token.tokenHash = tokenHash;
token.tokenPrefix = tokenPrefix;
token.protocols = options.protocols;
token.scopes = options.scopes;
token.createdAt = new Date();
token.createdIp = options.createdIp;
token.usageCount = 0;
token.isRevoked = false;
if (options.expiresInDays) {
token.expiresAt = new Date(Date.now() + options.expiresInDays * 24 * 60 * 60 * 1000);
}
await token.save();
// Audit log
await this.auditService.logTokenCreated(token.id, token.name);
return { rawToken, token };
}
/**
* Validate a raw token and return the token record and user
*/
public async validateToken(rawToken: string, ip?: string): Promise<ITokenValidationResult> {
// Check token format
if (!rawToken || !rawToken.startsWith('srg_') || rawToken.length !== 68) {
return {
valid: false,
errorCode: 'INVALID_TOKEN_FORMAT',
errorMessage: 'Invalid token format',
};
}
// Hash and lookup
const tokenHash = await this.hashToken(rawToken);
const token = await ApiToken.findByHash(tokenHash);
if (!token) {
return {
valid: false,
errorCode: 'TOKEN_NOT_FOUND',
errorMessage: 'Token not found',
};
}
// Check validity
if (!token.isValid()) {
if (token.isRevoked) {
return {
valid: false,
errorCode: 'TOKEN_REVOKED',
errorMessage: 'Token has been revoked',
};
}
return {
valid: false,
errorCode: 'TOKEN_EXPIRED',
errorMessage: 'Token has expired',
};
}
// Get user
const user = await User.findById(token.userId);
if (!user) {
return {
valid: false,
errorCode: 'USER_NOT_FOUND',
errorMessage: 'Token owner not found',
};
}
if (!user.isActive) {
return {
valid: false,
errorCode: 'USER_INACTIVE',
errorMessage: 'Token owner account is inactive',
};
}
// Record usage
await token.recordUsage(ip);
return {
valid: true,
token,
user,
};
}
/**
* Get all tokens for a user (without sensitive data)
*/
public async getUserTokens(userId: string): Promise<ApiToken[]> {
return await ApiToken.getUserTokens(userId);
}
/**
* Revoke a token
*/
public async revokeToken(tokenId: string, reason?: string): Promise<boolean> {
const token = await ApiToken.getInstance({ id: tokenId });
if (!token) return false;
await token.revoke(reason);
await this.auditService.logTokenRevoked(token.id, token.name);
return true;
}
/**
* Revoke all tokens for a user
*/
public async revokeAllUserTokens(userId: string, reason?: string): Promise<number> {
const tokens = await ApiToken.getUserTokens(userId);
for (const token of tokens) {
await token.revoke(reason);
await this.auditService.logTokenRevoked(token.id, token.name);
}
return tokens.length;
}
/**
* Check if token has permission for a specific action
*/
public checkTokenPermission(
token: ApiToken,
protocol: TRegistryProtocol,
organizationId?: string,
repositoryId?: string,
action?: string
): boolean {
if (!token.hasProtocol(protocol)) return false;
return token.hasScope(protocol, organizationId, repositoryId, action);
}
/**
* Hash a token using SHA-256
*/
private async hashToken(rawToken: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(rawToken);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generate token prefix for display
*/
public static getTokenDisplay(tokenPrefix: string): string {
return `${tokenPrefix}...`;
}
}