389 lines
10 KiB
TypeScript
389 lines
10 KiB
TypeScript
import type { IAuthConfig, IAuthToken, ICredentials, TRegistryProtocol } from './interfaces.core.js';
|
|
|
|
/**
|
|
* Unified authentication manager for all registry protocols
|
|
* Handles both NPM UUID tokens and OCI JWT tokens
|
|
*/
|
|
export class AuthManager {
|
|
private tokenStore: Map<string, IAuthToken> = new Map();
|
|
private userCredentials: Map<string, string> = new Map(); // username -> password hash (mock)
|
|
|
|
constructor(private config: IAuthConfig) {}
|
|
|
|
/**
|
|
* Initialize the auth manager
|
|
*/
|
|
public async init(): Promise<void> {
|
|
// Initialize token store (in-memory for now)
|
|
// In production, this could be Redis or a database
|
|
}
|
|
|
|
// ========================================================================
|
|
// NPM AUTHENTICATION
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Create an NPM token
|
|
* @param userId - User ID
|
|
* @param readonly - Whether the token is readonly
|
|
* @returns NPM UUID token
|
|
*/
|
|
public async createNpmToken(userId: string, readonly: boolean = false): Promise<string> {
|
|
if (!this.config.npmTokens.enabled) {
|
|
throw new Error('NPM tokens are not enabled');
|
|
}
|
|
|
|
const token = this.generateUuid();
|
|
const authToken: IAuthToken = {
|
|
type: 'npm',
|
|
userId,
|
|
scopes: readonly ? ['npm:*:*:read'] : ['npm:*:*:*'],
|
|
readonly,
|
|
metadata: {
|
|
created: new Date().toISOString(),
|
|
},
|
|
};
|
|
|
|
this.tokenStore.set(token, authToken);
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Validate an NPM token
|
|
* @param token - NPM UUID token
|
|
* @returns Auth token object or null
|
|
*/
|
|
public async validateNpmToken(token: string): Promise<IAuthToken | null> {
|
|
if (!this.isValidUuid(token)) {
|
|
return null;
|
|
}
|
|
|
|
const authToken = this.tokenStore.get(token);
|
|
if (!authToken || authToken.type !== 'npm') {
|
|
return null;
|
|
}
|
|
|
|
// Check expiration if set
|
|
if (authToken.expiresAt && authToken.expiresAt < new Date()) {
|
|
this.tokenStore.delete(token);
|
|
return null;
|
|
}
|
|
|
|
return authToken;
|
|
}
|
|
|
|
/**
|
|
* Revoke an NPM token
|
|
* @param token - NPM UUID token
|
|
*/
|
|
public async revokeNpmToken(token: string): Promise<void> {
|
|
this.tokenStore.delete(token);
|
|
}
|
|
|
|
/**
|
|
* List all tokens for a user
|
|
* @param userId - User ID
|
|
* @returns List of token info (without actual token values)
|
|
*/
|
|
public async listUserTokens(userId: string): Promise<Array<{
|
|
key: string;
|
|
readonly: boolean;
|
|
created: string;
|
|
}>> {
|
|
const tokens: Array<{key: string; readonly: boolean; created: string}> = [];
|
|
|
|
for (const [token, authToken] of this.tokenStore.entries()) {
|
|
if (authToken.userId === userId) {
|
|
tokens.push({
|
|
key: this.hashToken(token),
|
|
readonly: authToken.readonly || false,
|
|
created: authToken.metadata?.created || 'unknown',
|
|
});
|
|
}
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
// ========================================================================
|
|
// OCI AUTHENTICATION (JWT)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Create an OCI JWT token
|
|
* @param userId - User ID
|
|
* @param scopes - Permission scopes
|
|
* @param expiresIn - Expiration time in seconds
|
|
* @returns JWT token string
|
|
*/
|
|
public async createOciToken(
|
|
userId: string,
|
|
scopes: string[],
|
|
expiresIn: number = 3600
|
|
): Promise<string> {
|
|
if (!this.config.ociTokens.enabled) {
|
|
throw new Error('OCI tokens are not enabled');
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload = {
|
|
iss: this.config.ociTokens.realm,
|
|
sub: userId,
|
|
aud: this.config.ociTokens.service,
|
|
exp: now + expiresIn,
|
|
nbf: now,
|
|
iat: now,
|
|
access: this.scopesToOciAccess(scopes),
|
|
};
|
|
|
|
// In production, use proper JWT library with signing
|
|
// For now, return JSON string (mock JWT)
|
|
return JSON.stringify(payload);
|
|
}
|
|
|
|
/**
|
|
* Validate an OCI JWT token
|
|
* @param jwt - JWT token string
|
|
* @returns Auth token object or null
|
|
*/
|
|
public async validateOciToken(jwt: string): Promise<IAuthToken | null> {
|
|
try {
|
|
// In production, verify JWT signature
|
|
const payload = JSON.parse(jwt);
|
|
|
|
// Check expiration
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (payload.exp && payload.exp < now) {
|
|
return null;
|
|
}
|
|
|
|
// Convert to unified token format
|
|
const scopes = this.ociAccessToScopes(payload.access || []);
|
|
|
|
return {
|
|
type: 'oci',
|
|
userId: payload.sub,
|
|
scopes,
|
|
expiresAt: payload.exp ? new Date(payload.exp * 1000) : undefined,
|
|
metadata: {
|
|
iss: payload.iss,
|
|
aud: payload.aud,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// UNIFIED AUTHENTICATION
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Authenticate user credentials
|
|
* @param credentials - Username and password
|
|
* @returns User ID or null
|
|
*/
|
|
public async authenticate(credentials: ICredentials): Promise<string | null> {
|
|
// Mock authentication - in production, verify against database
|
|
const storedPassword = this.userCredentials.get(credentials.username);
|
|
|
|
if (!storedPassword) {
|
|
// Auto-register for testing (remove in production)
|
|
this.userCredentials.set(credentials.username, credentials.password);
|
|
return credentials.username;
|
|
}
|
|
|
|
if (storedPassword === credentials.password) {
|
|
return credentials.username;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Validate any token (NPM or OCI)
|
|
* @param tokenString - Token string (UUID or JWT)
|
|
* @param protocol - Expected protocol type
|
|
* @returns Auth token object or null
|
|
*/
|
|
public async validateToken(
|
|
tokenString: string,
|
|
protocol?: TRegistryProtocol
|
|
): Promise<IAuthToken | null> {
|
|
// Try NPM token first (UUID format)
|
|
if (this.isValidUuid(tokenString)) {
|
|
const npmToken = await this.validateNpmToken(tokenString);
|
|
if (npmToken && (!protocol || protocol === 'npm')) {
|
|
return npmToken;
|
|
}
|
|
}
|
|
|
|
// Try OCI JWT
|
|
const ociToken = await this.validateOciToken(tokenString);
|
|
if (ociToken && (!protocol || protocol === 'oci')) {
|
|
return ociToken;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if token has permission for an action
|
|
* @param token - Auth token
|
|
* @param resource - Resource being accessed (e.g., "package:foo" or "repository:bar")
|
|
* @param action - Action being performed (read, write, push, pull, delete)
|
|
* @returns true if authorized
|
|
*/
|
|
public async authorize(
|
|
token: IAuthToken | null,
|
|
resource: string,
|
|
action: string
|
|
): Promise<boolean> {
|
|
if (!token) {
|
|
return false;
|
|
}
|
|
|
|
// Check readonly flag
|
|
if (token.readonly && ['write', 'push', 'delete'].includes(action)) {
|
|
return false;
|
|
}
|
|
|
|
// Check scopes
|
|
for (const scope of token.scopes) {
|
|
if (this.matchesScope(scope, resource, action)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ========================================================================
|
|
// HELPER METHODS
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Check if a scope matches a resource and action
|
|
* Scope format: "{protocol}:{type}:{name}:{action}"
|
|
* Examples:
|
|
* - "npm:*:*" - All NPM access
|
|
* - "npm:package:foo:*" - All actions on package foo
|
|
* - "npm:package:foo:read" - Read-only on package foo
|
|
* - "oci:repository:*:pull" - Pull from any OCI repo
|
|
*/
|
|
private matchesScope(scope: string, resource: string, action: string): boolean {
|
|
const scopeParts = scope.split(':');
|
|
const resourceParts = resource.split(':');
|
|
|
|
// Scope must have at least protocol:type:name:action
|
|
if (scopeParts.length < 4) {
|
|
return false;
|
|
}
|
|
|
|
const [scopeProtocol, scopeType, scopeName, scopeAction] = scopeParts;
|
|
const [resourceProtocol, resourceType, resourceName] = resourceParts;
|
|
|
|
// Check protocol
|
|
if (scopeProtocol !== '*' && scopeProtocol !== resourceProtocol) {
|
|
return false;
|
|
}
|
|
|
|
// Check type
|
|
if (scopeType !== '*' && scopeType !== resourceType) {
|
|
return false;
|
|
}
|
|
|
|
// Check name
|
|
if (scopeName !== '*' && scopeName !== resourceName) {
|
|
return false;
|
|
}
|
|
|
|
// Check action
|
|
if (scopeAction !== '*' && scopeAction !== action) {
|
|
// Map action aliases
|
|
const actionAliases: Record<string, string[]> = {
|
|
read: ['pull', 'get'],
|
|
write: ['push', 'put', 'post'],
|
|
};
|
|
|
|
const aliases = actionAliases[scopeAction] || [];
|
|
if (!aliases.includes(action)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Convert unified scopes to OCI access array
|
|
*/
|
|
private scopesToOciAccess(scopes: string[]): Array<{
|
|
type: string;
|
|
name: string;
|
|
actions: string[];
|
|
}> {
|
|
const access: Array<{type: string; name: string; actions: string[]}> = [];
|
|
|
|
for (const scope of scopes) {
|
|
const parts = scope.split(':');
|
|
if (parts.length >= 4 && parts[0] === 'oci') {
|
|
access.push({
|
|
type: parts[1],
|
|
name: parts[2],
|
|
actions: [parts[3]],
|
|
});
|
|
}
|
|
}
|
|
|
|
return access;
|
|
}
|
|
|
|
/**
|
|
* Convert OCI access array to unified scopes
|
|
*/
|
|
private ociAccessToScopes(access: Array<{
|
|
type: string;
|
|
name: string;
|
|
actions: string[];
|
|
}>): string[] {
|
|
const scopes: string[] = [];
|
|
|
|
for (const item of access) {
|
|
for (const action of item.actions) {
|
|
scopes.push(`oci:${item.type}:${item.name}:${action}`);
|
|
}
|
|
}
|
|
|
|
return scopes;
|
|
}
|
|
|
|
/**
|
|
* Generate UUID for NPM tokens
|
|
*/
|
|
private generateUuid(): string {
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
const r = (Math.random() * 16) | 0;
|
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
return v.toString(16);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if string is a valid UUID
|
|
*/
|
|
private isValidUuid(str: string): boolean {
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
return uuidRegex.test(str);
|
|
}
|
|
|
|
/**
|
|
* Hash a token for identification (SHA-512 mock)
|
|
*/
|
|
private hashToken(token: string): string {
|
|
// In production, use actual SHA-512
|
|
return `sha512-${token.substring(0, 16)}...`;
|
|
}
|
|
}
|