Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d561b3874 | |||
| d3fd40ce2f | |||
| 44e92d48f2 |
15
changelog.md
15
changelog.md
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-03 - 1.3.0 - feat(auth)
|
||||
Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
|
||||
|
||||
- Introduce external authentication models: AuthProvider, ExternalIdentity, PlatformSettings to store provider configs, links, and platform auth settings
|
||||
- Add AuthProvider admin API (AdminAuthApi) to create/update/delete/test providers and manage platform auth settings
|
||||
- Add public OAuth endpoints (OAuthApi) for listing providers, initiating OAuth flows, handling callbacks, and LDAP login
|
||||
- Implement ExternalAuthService to orchestrate OAuth and LDAP flows, user provisioning, linking, session/token generation, and provider testing
|
||||
- Add pluggable auth strategy pattern with OAuthStrategy and LdapStrategy plus AuthStrategyFactory to select appropriate strategy
|
||||
- Add CryptoService for AES-256-GCM encryption/decryption of provider secrets and helper for key generation
|
||||
- Extend AuthService and session/user handling to support tokens/sessions created by external auth flows and user provisioning flags
|
||||
- Add UI: admin pages for managing auth providers (list, provider form, connection test) and login enhancements (SSO buttons, LDAP form, oauth-callback handler)
|
||||
- Add client-side AdminAuthService for communicating with new admin auth endpoints and an adminGuard for route protection
|
||||
- Register new API routes in ApiRouter and wire server-side handlers into the router
|
||||
- Implement safeguards: mask secrets in admin responses, validate provider configs, and track connection test results and audit logs
|
||||
|
||||
## 2025-11-28 - 1.2.0 - feat(tokens)
|
||||
Add support for organization-owned API tokens and org-level token management
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"start": "deno run --allow-all mod.ts server",
|
||||
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
||||
"test": "deno test --allow-all",
|
||||
"test": "deno test --allow-all --no-check test/",
|
||||
"test:unit": "deno test --allow-all --no-check test/unit/",
|
||||
"test:integration": "deno test --allow-all --no-check test/integration/",
|
||||
"test:e2e": "deno test --allow-all --no-check test/e2e/",
|
||||
"test:docker-up": "docker compose -f test/docker-compose.test.yml up -d --wait",
|
||||
"test:docker-down": "docker compose -f test/docker-compose.test.yml down -v",
|
||||
"build": "cd ui && pnpm run build",
|
||||
"bundle-ui": "deno run --allow-all scripts/bundle-ui.ts",
|
||||
"bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch",
|
||||
|
||||
16
deno.lock
generated
16
deno.lock
generated
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@std/assert@*": "1.0.16",
|
||||
"jsr:@std/assert@^1.0.15": "1.0.16",
|
||||
"jsr:@std/cli@^1.0.24": "1.0.24",
|
||||
"jsr:@std/encoding@1": "1.0.10",
|
||||
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||
@@ -15,6 +17,7 @@
|
||||
"jsr:@std/path@1": "1.1.3",
|
||||
"jsr:@std/path@^1.1.3": "1.1.3",
|
||||
"jsr:@std/streams@^1.0.14": "1.0.14",
|
||||
"jsr:@std/testing@*": "1.0.16",
|
||||
"npm:@push.rocks/smartarchive@5": "5.0.1",
|
||||
"npm:@push.rocks/smartbucket@^4.3.0": "4.3.0",
|
||||
"npm:@push.rocks/smartcli@4": "4.0.19",
|
||||
@@ -34,6 +37,12 @@
|
||||
"npm:concurrently@^9.1.2": "9.2.1"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.16": {
|
||||
"integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/cli@1.0.24": {
|
||||
"integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e"
|
||||
},
|
||||
@@ -84,6 +93,13 @@
|
||||
},
|
||||
"@std/streams@1.0.14": {
|
||||
"integrity": "c0df6cdd73bd4bbcbe4baa89e323b88418c90ceb2d926f95aa99bdcdbfca2411"
|
||||
},
|
||||
"@std/testing@1.0.16": {
|
||||
"integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.15",
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "Enterprise-grade multi-protocol package registry",
|
||||
"type": "module",
|
||||
|
||||
48
test/docker-compose.test.yml
Normal file
48
test/docker-compose.test.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
mongodb-test:
|
||||
image: mongo:7
|
||||
container_name: stack-gallery-test-mongo
|
||||
ports:
|
||||
- "27117:27017"
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: testadmin
|
||||
MONGO_INITDB_ROOT_PASSWORD: testpass
|
||||
tmpfs:
|
||||
- /data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-test:
|
||||
image: minio/minio:latest
|
||||
container_name: stack-gallery-test-minio
|
||||
ports:
|
||||
- "9100:9000"
|
||||
- "9101:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: testadmin
|
||||
MINIO_ROOT_PASSWORD: testpassword
|
||||
command: server /data --console-address ":9001"
|
||||
tmpfs:
|
||||
- /data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-setup:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio-test:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set testminio http://minio-test:9000 testadmin testpassword;
|
||||
mc mb testminio/test-registry --ignore-existing;
|
||||
exit 0;
|
||||
"
|
||||
290
test/e2e/npm.e2e.test.ts
Normal file
290
test/e2e/npm.e2e.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* NPM Protocol E2E Tests
|
||||
*
|
||||
* Tests the full NPM package lifecycle: publish -> fetch -> delete
|
||||
* Requires: npm CLI, running registry, Docker test infrastructure
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import * as path from '@std/path';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
createTestApiToken,
|
||||
clients,
|
||||
skipIfMissing,
|
||||
runCommand,
|
||||
testConfig,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
const FIXTURE_DIR = path.join(
|
||||
path.dirname(path.fromFileUrl(import.meta.url)),
|
||||
'../fixtures/npm/@stack-test/demo-package'
|
||||
);
|
||||
|
||||
describe('NPM E2E: Full lifecycle', () => {
|
||||
let testUserId: string;
|
||||
let testOrgName: string;
|
||||
let apiToken: string;
|
||||
let registryUrl: string;
|
||||
let shouldSkip = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Check if npm is available
|
||||
shouldSkip = await skipIfMissing('npm');
|
||||
if (shouldSkip) return;
|
||||
|
||||
await setupTestDb();
|
||||
registryUrl = testConfig.registry.url;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!shouldSkip) {
|
||||
await teardownTestDb();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (shouldSkip) return;
|
||||
|
||||
await cleanupTestDb();
|
||||
|
||||
// Create test user and org
|
||||
const { user } = await createTestUser({ status: 'active' });
|
||||
testUserId = user.id;
|
||||
|
||||
const { organization } = await createOrgWithOwner(testUserId, { name: 'npm-test' });
|
||||
testOrgName = organization.name;
|
||||
|
||||
// Create repository for npm packages
|
||||
await createTestRepository({
|
||||
organizationId: organization.id,
|
||||
createdById: testUserId,
|
||||
name: 'packages',
|
||||
protocol: 'npm',
|
||||
});
|
||||
|
||||
// Create API token with npm permissions
|
||||
const { rawToken } = await createTestApiToken({
|
||||
userId: testUserId,
|
||||
name: 'npm-publish-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read', 'write', 'delete'] }],
|
||||
});
|
||||
apiToken = rawToken;
|
||||
});
|
||||
|
||||
it('should publish package', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure npm to use our registry
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `
|
||||
//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}
|
||||
@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/
|
||||
`;
|
||||
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
try {
|
||||
const result = await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
assertEquals(result.success, true, `npm publish failed: ${result.stderr}`);
|
||||
} finally {
|
||||
// Cleanup .npmrc
|
||||
try {
|
||||
await Deno.remove(npmrcPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should fetch package metadata', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// First publish
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
try {
|
||||
await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
// Fetch metadata via npm view
|
||||
const viewResult = await runCommand(
|
||||
['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`],
|
||||
{ env: { npm_config__authToken: apiToken } }
|
||||
);
|
||||
|
||||
assertEquals(viewResult.success, true, `npm view failed: ${viewResult.stderr}`);
|
||||
assertEquals(viewResult.stdout.includes('@stack-test/demo-package'), true);
|
||||
} finally {
|
||||
try {
|
||||
await Deno.remove(npmrcPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should install package', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temp directory for installation
|
||||
const tempDir = await Deno.makeTempDir({ prefix: 'npm-e2e-' });
|
||||
|
||||
try {
|
||||
// First publish
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
// Create package.json in temp dir
|
||||
await Deno.writeTextFile(
|
||||
path.join(tempDir, 'package.json'),
|
||||
JSON.stringify({ name: 'test-install', version: '1.0.0' })
|
||||
);
|
||||
|
||||
// Create .npmrc in temp dir
|
||||
await Deno.writeTextFile(
|
||||
path.join(tempDir, '.npmrc'),
|
||||
`@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/\n//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`
|
||||
);
|
||||
|
||||
// Install
|
||||
const installResult = await clients.npm.install(
|
||||
'@stack-test/demo-package@1.0.0',
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
tempDir
|
||||
);
|
||||
|
||||
assertEquals(installResult.success, true, `npm install failed: ${installResult.stderr}`);
|
||||
|
||||
// Verify installed
|
||||
const pkgPath = path.join(tempDir, 'node_modules/@stack-test/demo-package');
|
||||
const stat = await Deno.stat(pkgPath);
|
||||
assertEquals(stat.isDirectory, true);
|
||||
|
||||
// Cleanup fixture .npmrc
|
||||
try {
|
||||
await Deno.remove(npmrcPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} finally {
|
||||
await Deno.remove(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should unpublish package', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// First publish
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
try {
|
||||
await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
// Unpublish
|
||||
const unpublishResult = await clients.npm.unpublish(
|
||||
'@stack-test/demo-package@1.0.0',
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
unpublishResult.success,
|
||||
true,
|
||||
`npm unpublish failed: ${unpublishResult.stderr}`
|
||||
);
|
||||
|
||||
// Verify package is gone
|
||||
const viewResult = await runCommand(
|
||||
['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`],
|
||||
{ env: { npm_config__authToken: apiToken } }
|
||||
);
|
||||
|
||||
// Should fail since package was unpublished
|
||||
assertEquals(viewResult.success, false);
|
||||
} finally {
|
||||
try {
|
||||
await Deno.remove(npmrcPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('NPM E2E: Edge cases', () => {
|
||||
let shouldSkip = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
shouldSkip = await skipIfMissing('npm');
|
||||
});
|
||||
|
||||
it('should handle scoped packages correctly', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Test scoped package name handling
|
||||
const scopedName = '@stack-test/demo-package';
|
||||
assertEquals(scopedName.startsWith('@'), true);
|
||||
assertEquals(scopedName.includes('/'), true);
|
||||
});
|
||||
|
||||
it('should reject invalid package names', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// npm has strict naming rules
|
||||
const invalidNames = ['UPPERCASE', '..dots..', 'spaces here', '_underscore'];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
// Just verify these are considered invalid by npm standards
|
||||
assertEquals(!/^[a-z0-9][-a-z0-9._]*$/.test(name), true);
|
||||
}
|
||||
});
|
||||
});
|
||||
190
test/e2e/oci.e2e.test.ts
Normal file
190
test/e2e/oci.e2e.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* OCI Protocol E2E Tests
|
||||
*
|
||||
* Tests the full OCI container image lifecycle: push -> pull -> delete
|
||||
* Requires: docker CLI, running registry, Docker test infrastructure
|
||||
*/
|
||||
|
||||
import { assertEquals } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import * as path from '@std/path';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
createTestApiToken,
|
||||
clients,
|
||||
skipIfMissing,
|
||||
testConfig,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
const FIXTURE_DIR = path.join(
|
||||
path.dirname(path.fromFileUrl(import.meta.url)),
|
||||
'../fixtures/oci'
|
||||
);
|
||||
|
||||
describe('OCI E2E: Full lifecycle', () => {
|
||||
let testUserId: string;
|
||||
let testOrgName: string;
|
||||
let apiToken: string;
|
||||
let registryHost: string;
|
||||
let shouldSkip = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Check if docker is available
|
||||
shouldSkip = await skipIfMissing('docker');
|
||||
if (shouldSkip) return;
|
||||
|
||||
await setupTestDb();
|
||||
const url = new URL(testConfig.registry.url);
|
||||
registryHost = url.host;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!shouldSkip) {
|
||||
await teardownTestDb();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (shouldSkip) return;
|
||||
|
||||
await cleanupTestDb();
|
||||
|
||||
// Create test user and org
|
||||
const { user } = await createTestUser({ status: 'active' });
|
||||
testUserId = user.id;
|
||||
|
||||
const { organization } = await createOrgWithOwner(testUserId, { name: 'oci-test' });
|
||||
testOrgName = organization.name;
|
||||
|
||||
// Create repository for OCI images
|
||||
await createTestRepository({
|
||||
organizationId: organization.id,
|
||||
createdById: testUserId,
|
||||
name: 'images',
|
||||
protocol: 'oci',
|
||||
});
|
||||
|
||||
// Create API token with OCI permissions
|
||||
const { rawToken } = await createTestApiToken({
|
||||
userId: testUserId,
|
||||
name: 'oci-push-token',
|
||||
protocols: ['oci'],
|
||||
scopes: [{ protocol: 'oci', actions: ['read', 'write', 'delete'] }],
|
||||
});
|
||||
apiToken = rawToken;
|
||||
});
|
||||
|
||||
it('should build and push image', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`;
|
||||
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple');
|
||||
|
||||
try {
|
||||
// Build image
|
||||
const buildResult = await clients.docker.build(dockerfile, imageName, FIXTURE_DIR);
|
||||
assertEquals(buildResult.success, true, `docker build failed: ${buildResult.stderr}`);
|
||||
|
||||
// Login to registry
|
||||
const loginResult = await clients.docker.login(registryHost, 'token', apiToken);
|
||||
assertEquals(loginResult.success, true, `docker login failed: ${loginResult.stderr}`);
|
||||
|
||||
// Push image
|
||||
const pushResult = await clients.docker.push(imageName);
|
||||
assertEquals(pushResult.success, true, `docker push failed: ${pushResult.stderr}`);
|
||||
} finally {
|
||||
// Cleanup local image
|
||||
await clients.docker.rmi(imageName, true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should pull image', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`;
|
||||
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple');
|
||||
|
||||
try {
|
||||
// Build and push first
|
||||
await clients.docker.build(dockerfile, imageName, FIXTURE_DIR);
|
||||
await clients.docker.login(registryHost, 'token', apiToken);
|
||||
await clients.docker.push(imageName);
|
||||
|
||||
// Remove local image
|
||||
await clients.docker.rmi(imageName, true);
|
||||
|
||||
// Pull from registry
|
||||
const pullResult = await clients.docker.pull(imageName);
|
||||
assertEquals(pullResult.success, true, `docker pull failed: ${pullResult.stderr}`);
|
||||
} finally {
|
||||
await clients.docker.rmi(imageName, true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multi-layer images', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageName = `${registryHost}/v2/${testOrgName}/multi:1.0.0`;
|
||||
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.multi-layer');
|
||||
|
||||
try {
|
||||
// Build multi-stage image
|
||||
const buildResult = await clients.docker.build(dockerfile, imageName, FIXTURE_DIR);
|
||||
assertEquals(buildResult.success, true, `docker build failed: ${buildResult.stderr}`);
|
||||
|
||||
// Login and push
|
||||
await clients.docker.login(registryHost, 'token', apiToken);
|
||||
const pushResult = await clients.docker.push(imageName);
|
||||
assertEquals(pushResult.success, true, `docker push failed: ${pushResult.stderr}`);
|
||||
} finally {
|
||||
await clients.docker.rmi(imageName, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCI E2E: Tags and versions', () => {
|
||||
let shouldSkip = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
shouldSkip = await skipIfMissing('docker');
|
||||
});
|
||||
|
||||
it('should handle multiple tags for same image', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify tag handling logic
|
||||
const tags = ['1.0.0', '1.0', '1', 'latest'];
|
||||
for (const tag of tags) {
|
||||
assertEquals(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(tag), true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle SHA256 digests', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify digest format
|
||||
const digest = 'sha256:' + 'a'.repeat(64);
|
||||
assertEquals(digest.startsWith('sha256:'), true);
|
||||
assertEquals(digest.length, 71);
|
||||
});
|
||||
});
|
||||
15
test/fixtures/cargo/demo-crate/Cargo.toml
vendored
Normal file
15
test/fixtures/cargo/demo-crate/Cargo.toml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "demo-crate"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
authors = ["Stack.Gallery Test <test@stack.gallery>"]
|
||||
description = "Demo crate for Stack.Gallery Registry e2e tests"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/stack-gallery/demo-crate"
|
||||
readme = "README.md"
|
||||
keywords = ["demo", "test", "stack-gallery"]
|
||||
categories = ["development-tools"]
|
||||
|
||||
[lib]
|
||||
name = "demo_crate"
|
||||
path = "src/lib.rs"
|
||||
13
test/fixtures/cargo/demo-crate/README.md
vendored
Normal file
13
test/fixtures/cargo/demo-crate/README.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# demo-crate
|
||||
|
||||
Demo crate for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use demo_crate::greet;
|
||||
|
||||
fn main() {
|
||||
println!("{}", greet("World")); // Hello, World!
|
||||
}
|
||||
```
|
||||
16
test/fixtures/cargo/demo-crate/src/lib.rs
vendored
Normal file
16
test/fixtures/cargo/demo-crate/src/lib.rs
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
//! Demo crate for Stack.Gallery Registry e2e tests
|
||||
|
||||
/// Greets the given name
|
||||
pub fn greet(name: &str) -> String {
|
||||
format!("Hello, {}!", name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_greet() {
|
||||
assert_eq!(greet("World"), "Hello, World!");
|
||||
}
|
||||
}
|
||||
13
test/fixtures/composer/stacktest/demo-package/README.md
vendored
Normal file
13
test/fixtures/composer/stacktest/demo-package/README.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# stacktest/demo-package
|
||||
|
||||
Demo package for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use StackTest\DemoPackage\Demo;
|
||||
|
||||
echo Demo::greet("World"); // Hello, World!
|
||||
```
|
||||
21
test/fixtures/composer/stacktest/demo-package/composer.json
vendored
Normal file
21
test/fixtures/composer/stacktest/demo-package/composer.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "stacktest/demo-package",
|
||||
"description": "Demo package for Stack.Gallery Registry e2e tests",
|
||||
"version": "1.0.0",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Stack.Gallery Test",
|
||||
"email": "test@stack.gallery"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"StackTest\\DemoPackage\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
test/fixtures/composer/stacktest/demo-package/src/Demo.php
vendored
Normal file
20
test/fixtures/composer/stacktest/demo-package/src/Demo.php
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace StackTest\DemoPackage;
|
||||
|
||||
/**
|
||||
* Demo class for Stack.Gallery Registry e2e tests.
|
||||
*/
|
||||
class Demo
|
||||
{
|
||||
/**
|
||||
* Greet the given name.
|
||||
*
|
||||
* @param string $name The name to greet
|
||||
* @return string A greeting message
|
||||
*/
|
||||
public static function greet(string $name): string
|
||||
{
|
||||
return "Hello, {$name}!";
|
||||
}
|
||||
}
|
||||
34
test/fixtures/maven/com/stacktest/demo-artifact/pom.xml
vendored
Normal file
34
test/fixtures/maven/com/stacktest/demo-artifact/pom.xml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.stacktest</groupId>
|
||||
<artifactId>demo-artifact</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>Stack.Gallery Demo Artifact</name>
|
||||
<description>Demo Maven artifact for e2e tests</description>
|
||||
<url>https://github.com/stack-gallery/demo-artifact</url>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>MIT License</name>
|
||||
<url>https://opensource.org/licenses/MIT</url>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<name>Stack.Gallery Test</name>
|
||||
<email>test@stack.gallery</email>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
</project>
|
||||
19
test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java
vendored
Normal file
19
test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.stacktest;
|
||||
|
||||
/**
|
||||
* Demo class for Stack.Gallery Registry e2e tests.
|
||||
*/
|
||||
public class Demo {
|
||||
/**
|
||||
* Greet the given name.
|
||||
* @param name The name to greet
|
||||
* @return A greeting message
|
||||
*/
|
||||
public static String greet(String name) {
|
||||
return "Hello, " + name + "!";
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println(greet("World"));
|
||||
}
|
||||
}
|
||||
10
test/fixtures/npm/@stack-test/demo-package/README.md
vendored
Normal file
10
test/fixtures/npm/@stack-test/demo-package/README.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# @stack-test/demo-package
|
||||
|
||||
Demo package for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
const demo = require('@stack-test/demo-package');
|
||||
console.log(demo.greet('World')); // Hello, World!
|
||||
```
|
||||
9
test/fixtures/npm/@stack-test/demo-package/index.js
vendored
Normal file
9
test/fixtures/npm/@stack-test/demo-package/index.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Demo package for Stack.Gallery Registry e2e tests
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
name: 'demo-package',
|
||||
greet: (name) => `Hello, ${name}!`,
|
||||
version: () => require('./package.json').version
|
||||
};
|
||||
13
test/fixtures/npm/@stack-test/demo-package/package.json
vendored
Normal file
13
test/fixtures/npm/@stack-test/demo-package/package.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@stack-test/demo-package",
|
||||
"version": "1.0.0",
|
||||
"description": "Demo package for Stack.Gallery Registry e2e tests",
|
||||
"main": "index.js",
|
||||
"author": "Stack.Gallery Test <test@stack.gallery>",
|
||||
"license": "MIT",
|
||||
"keywords": ["demo", "test", "stack-gallery"],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/stack-gallery/demo-package"
|
||||
}
|
||||
}
|
||||
9
test/fixtures/oci/Dockerfile.multi-layer
vendored
Normal file
9
test/fixtures/oci/Dockerfile.multi-layer
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM alpine:3.19 AS builder
|
||||
RUN echo "Building..." > /build.log
|
||||
|
||||
FROM alpine:3.19
|
||||
LABEL org.opencontainers.image.title="stack-test-demo-multi"
|
||||
LABEL org.opencontainers.image.version="1.0.0"
|
||||
COPY --from=builder /build.log /build.log
|
||||
RUN echo "Stack.Gallery Multi-Layer Demo" > /README.txt
|
||||
CMD ["cat", "/README.txt"]
|
||||
6
test/fixtures/oci/Dockerfile.simple
vendored
Normal file
6
test/fixtures/oci/Dockerfile.simple
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM alpine:3.19
|
||||
LABEL org.opencontainers.image.title="stack-test-demo"
|
||||
LABEL org.opencontainers.image.version="1.0.0"
|
||||
LABEL org.opencontainers.image.description="Demo image for Stack.Gallery Registry e2e tests"
|
||||
RUN echo "Stack.Gallery Demo Image" > /README.txt
|
||||
CMD ["cat", "/README.txt"]
|
||||
11
test/fixtures/pypi/demo_package/README.md
vendored
Normal file
11
test/fixtures/pypi/demo_package/README.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# stack-test-demo-package
|
||||
|
||||
Demo package for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from demo_package import greet
|
||||
|
||||
print(greet("World")) # Hello, World!
|
||||
```
|
||||
8
test/fixtures/pypi/demo_package/demo_package/__init__.py
vendored
Normal file
8
test/fixtures/pypi/demo_package/demo_package/__init__.py
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Demo package for Stack.Gallery Registry e2e tests."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
|
||||
def greet(name: str) -> str:
|
||||
"""Greet the given name."""
|
||||
return f"Hello, {name}!"
|
||||
23
test/fixtures/pypi/demo_package/pyproject.toml
vendored
Normal file
23
test/fixtures/pypi/demo_package/pyproject.toml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "stack-test-demo-package"
|
||||
version = "1.0.0"
|
||||
description = "Demo package for Stack.Gallery Registry e2e tests"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Stack.Gallery Test", email = "test@stack.gallery"}
|
||||
]
|
||||
keywords = ["demo", "test", "stack-gallery"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
11
test/fixtures/rubygems/demo-gem/README.md
vendored
Normal file
11
test/fixtures/rubygems/demo-gem/README.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# stack-test-demo-gem
|
||||
|
||||
Demo gem for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```ruby
|
||||
require 'demo-gem'
|
||||
|
||||
puts StackTestDemoGem.greet("World") # Hello, World!
|
||||
```
|
||||
16
test/fixtures/rubygems/demo-gem/demo-gem.gemspec
vendored
Normal file
16
test/fixtures/rubygems/demo-gem/demo-gem.gemspec
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
Gem::Specification.new do |spec|
|
||||
spec.name = "stack-test-demo-gem"
|
||||
spec.version = "1.0.0"
|
||||
spec.authors = ["Stack.Gallery Test"]
|
||||
spec.email = ["test@stack.gallery"]
|
||||
|
||||
spec.summary = "Demo gem for Stack.Gallery Registry e2e tests"
|
||||
spec.description = "A demonstration gem for testing Stack.Gallery Registry"
|
||||
spec.homepage = "https://github.com/stack-gallery/demo-gem"
|
||||
spec.license = "MIT"
|
||||
|
||||
spec.required_ruby_version = ">= 2.7.0"
|
||||
|
||||
spec.files = Dir["lib/**/*", "README.md"]
|
||||
spec.require_paths = ["lib"]
|
||||
end
|
||||
13
test/fixtures/rubygems/demo-gem/lib/demo-gem.rb
vendored
Normal file
13
test/fixtures/rubygems/demo-gem/lib/demo-gem.rb
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Demo gem for Stack.Gallery Registry e2e tests
|
||||
module StackTestDemoGem
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Greet the given name
|
||||
# @param name [String] The name to greet
|
||||
# @return [String] A greeting message
|
||||
def self.greet(name)
|
||||
"Hello, #{name}!"
|
||||
end
|
||||
end
|
||||
141
test/helpers/auth.helper.ts
Normal file
141
test/helpers/auth.helper.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Authentication test helper - creates test users, tokens, and sessions
|
||||
*/
|
||||
|
||||
import { User } from '../../ts/models/user.ts';
|
||||
import { ApiToken } from '../../ts/models/apitoken.ts';
|
||||
import { AuthService } from '../../ts/services/auth.service.ts';
|
||||
import { TokenService } from '../../ts/services/token.service.ts';
|
||||
import type { TRegistryProtocol, ITokenScope, TUserStatus } from '../../ts/interfaces/auth.interfaces.ts';
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
const TEST_PASSWORD = 'TestPassword123!';
|
||||
|
||||
export interface ICreateTestUserOptions {
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
displayName?: string;
|
||||
status?: TUserStatus;
|
||||
isPlatformAdmin?: boolean;
|
||||
emailVerified?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test user with sensible defaults
|
||||
*/
|
||||
export async function createTestUser(
|
||||
overrides: ICreateTestUserOptions = {}
|
||||
): Promise<{ user: User; password: string }> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
const password = overrides.password || TEST_PASSWORD;
|
||||
const passwordHash = await User.hashPassword(password);
|
||||
|
||||
const user = await User.createUser({
|
||||
email: overrides.email || `test-${uniqueId}@example.com`,
|
||||
username: overrides.username || `testuser-${uniqueId}`,
|
||||
passwordHash,
|
||||
displayName: overrides.displayName || `Test User ${uniqueId}`,
|
||||
});
|
||||
|
||||
// Set additional properties
|
||||
user.status = overrides.status || 'active';
|
||||
user.emailVerified = overrides.emailVerified ?? true;
|
||||
if (overrides.isPlatformAdmin) {
|
||||
user.isPlatformAdmin = true;
|
||||
}
|
||||
await user.save();
|
||||
|
||||
return { user, password };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create admin user
|
||||
*/
|
||||
export async function createAdminUser(): Promise<{ user: User; password: string }> {
|
||||
return createTestUser({ isPlatformAdmin: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Login and get tokens
|
||||
*/
|
||||
export async function loginUser(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
|
||||
const authService = new AuthService({
|
||||
jwtSecret: testConfig.jwt.secret,
|
||||
});
|
||||
|
||||
const result = await authService.login(email, password, {
|
||||
userAgent: 'TestAgent/1.0',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Login failed: ${result.errorMessage}`);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: result.accessToken!,
|
||||
refreshToken: result.refreshToken!,
|
||||
sessionId: result.sessionId!,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ICreateTestApiTokenOptions {
|
||||
userId: string;
|
||||
name?: string;
|
||||
protocols?: TRegistryProtocol[];
|
||||
scopes?: ITokenScope[];
|
||||
organizationId?: string;
|
||||
expiresInDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test API token
|
||||
*/
|
||||
export async function createTestApiToken(
|
||||
options: ICreateTestApiTokenOptions
|
||||
): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
const tokenService = new TokenService();
|
||||
|
||||
return tokenService.createToken({
|
||||
userId: options.userId,
|
||||
organizationId: options.organizationId,
|
||||
name: options.name || `test-token-${crypto.randomUUID().slice(0, 8)}`,
|
||||
protocols: options.protocols || ['npm', 'oci'],
|
||||
scopes: options.scopes || [
|
||||
{
|
||||
protocol: '*',
|
||||
actions: ['read', 'write', 'delete'],
|
||||
},
|
||||
],
|
||||
expiresInDays: options.expiresInDays,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth header for API requests
|
||||
*/
|
||||
export function createAuthHeader(token: string): { Authorization: string } {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create basic auth header (for registry protocols)
|
||||
*/
|
||||
export function createBasicAuthHeader(
|
||||
username: string,
|
||||
password: string
|
||||
): { Authorization: string } {
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default test password
|
||||
*/
|
||||
export function getTestPassword(): string {
|
||||
return TEST_PASSWORD;
|
||||
}
|
||||
106
test/helpers/db.helper.ts
Normal file
106
test/helpers/db.helper.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Database test helper - manages test database lifecycle
|
||||
*
|
||||
* NOTE: The smartdata models use a global `db` singleton. This helper
|
||||
* ensures proper initialization and cleanup for tests.
|
||||
*/
|
||||
|
||||
import * as plugins from '../../ts/plugins.ts';
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
// Test database instance - separate from production
|
||||
let testDb: plugins.smartdata.SmartdataDb | null = null;
|
||||
let testDbName: string = '';
|
||||
let isConnected = false;
|
||||
|
||||
// We need to patch the global db export since models reference it
|
||||
// This is done by re-initializing with the test config
|
||||
import { initDb, closeDb } from '../../ts/models/db.ts';
|
||||
|
||||
/**
|
||||
* Initialize test database with unique name per test run
|
||||
*/
|
||||
export async function setupTestDb(config?: {
|
||||
mongoUrl?: string;
|
||||
dbName?: string;
|
||||
}): Promise<void> {
|
||||
// If already connected, reuse the connection
|
||||
if (isConnected && testDb) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mongoUrl = config?.mongoUrl || testConfig.mongodb.url;
|
||||
|
||||
// Generate unique database name for this test session
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
testDbName = config?.dbName || `${testConfig.mongodb.name}-${uniqueId}`;
|
||||
|
||||
// Initialize the global db singleton with test configuration
|
||||
testDb = await initDb(mongoUrl, testDbName);
|
||||
isConnected = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test database - deletes all documents from collections
|
||||
* This is safer than dropping collections which causes index rebuild issues
|
||||
*/
|
||||
export async function cleanupTestDb(): Promise<void> {
|
||||
if (!testDb || !isConnected) return;
|
||||
|
||||
try {
|
||||
const collections = await testDb.mongoDb.listCollections().toArray();
|
||||
for (const col of collections) {
|
||||
// Delete all documents but preserve indexes
|
||||
await testDb.mongoDb.collection(col.name).deleteMany({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[TestHelper] Error cleaning database:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown test database - drops database and closes connection
|
||||
*/
|
||||
export async function teardownTestDb(): Promise<void> {
|
||||
if (!testDb || !isConnected) return;
|
||||
|
||||
try {
|
||||
// Drop the test database
|
||||
await testDb.mongoDb.dropDatabase();
|
||||
// Close the connection
|
||||
await closeDb();
|
||||
testDb = null;
|
||||
isConnected = false;
|
||||
} catch (error) {
|
||||
console.warn('[TestHelper] Error tearing down database:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific collection(s) - deletes all documents
|
||||
*/
|
||||
export async function clearCollections(...collectionNames: string[]): Promise<void> {
|
||||
if (!testDb || !isConnected) return;
|
||||
|
||||
for (const name of collectionNames) {
|
||||
try {
|
||||
await testDb.mongoDb.collection(name).deleteMany({});
|
||||
} catch {
|
||||
// Collection may not exist, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current test database name
|
||||
*/
|
||||
export function getTestDbName(): string {
|
||||
return testDbName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database instance for direct access
|
||||
*/
|
||||
export function getTestDb(): plugins.smartdata.SmartdataDb | null {
|
||||
return testDb;
|
||||
}
|
||||
268
test/helpers/factory.helper.ts
Normal file
268
test/helpers/factory.helper.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Factory helper - creates test entities with sensible defaults
|
||||
*/
|
||||
|
||||
import { Organization } from '../../ts/models/organization.ts';
|
||||
import { OrganizationMember } from '../../ts/models/organization.member.ts';
|
||||
import { Repository } from '../../ts/models/repository.ts';
|
||||
import { Team } from '../../ts/models/team.ts';
|
||||
import { TeamMember } from '../../ts/models/team.member.ts';
|
||||
import { Package } from '../../ts/models/package.ts';
|
||||
import { RepositoryPermission } from '../../ts/models/repository.permission.ts';
|
||||
import type {
|
||||
TOrganizationRole,
|
||||
TTeamRole,
|
||||
TRepositoryRole,
|
||||
TRepositoryVisibility,
|
||||
TRegistryProtocol,
|
||||
} from '../../ts/interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface ICreateTestOrganizationOptions {
|
||||
createdById: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test organization
|
||||
*/
|
||||
export async function createTestOrganization(
|
||||
options: ICreateTestOrganizationOptions
|
||||
): Promise<Organization> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
const org = await Organization.createOrganization({
|
||||
name: options.name || `test-org-${uniqueId}`,
|
||||
displayName: options.displayName || `Test Org ${uniqueId}`,
|
||||
description: options.description || 'Test organization',
|
||||
createdById: options.createdById,
|
||||
});
|
||||
|
||||
if (options.isPublic !== undefined) {
|
||||
org.isPublic = options.isPublic;
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return org;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization with owner membership
|
||||
*/
|
||||
export async function createOrgWithOwner(
|
||||
ownerId: string,
|
||||
orgOptions?: Partial<ICreateTestOrganizationOptions>
|
||||
): Promise<{
|
||||
organization: Organization;
|
||||
membership: OrganizationMember;
|
||||
}> {
|
||||
const organization = await createTestOrganization({
|
||||
createdById: ownerId,
|
||||
...orgOptions,
|
||||
});
|
||||
|
||||
const membership = await OrganizationMember.addMember({
|
||||
organizationId: organization.id,
|
||||
userId: ownerId,
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
organization.memberCount = 1;
|
||||
await organization.save();
|
||||
|
||||
return { organization, membership };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add member to organization
|
||||
*/
|
||||
export async function addOrgMember(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
role: TOrganizationRole = 'member',
|
||||
invitedBy?: string
|
||||
): Promise<OrganizationMember> {
|
||||
const membership = await OrganizationMember.addMember({
|
||||
organizationId,
|
||||
userId,
|
||||
role,
|
||||
invitedBy,
|
||||
});
|
||||
|
||||
const org = await Organization.findById(organizationId);
|
||||
if (org) {
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
export interface ICreateTestRepositoryOptions {
|
||||
organizationId: string;
|
||||
createdById: string;
|
||||
name?: string;
|
||||
protocol?: TRegistryProtocol;
|
||||
visibility?: TRepositoryVisibility;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test repository
|
||||
*/
|
||||
export async function createTestRepository(
|
||||
options: ICreateTestRepositoryOptions
|
||||
): Promise<Repository> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
return Repository.createRepository({
|
||||
organizationId: options.organizationId,
|
||||
name: options.name || `test-repo-${uniqueId}`,
|
||||
protocol: options.protocol || 'npm',
|
||||
visibility: options.visibility || 'private',
|
||||
description: options.description || 'Test repository',
|
||||
createdById: options.createdById,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ICreateTestTeamOptions {
|
||||
organizationId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test team
|
||||
*/
|
||||
export async function createTestTeam(options: ICreateTestTeamOptions): Promise<Team> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
return Team.createTeam({
|
||||
organizationId: options.organizationId,
|
||||
name: options.name || `test-team-${uniqueId}`,
|
||||
description: options.description || 'Test team',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add member to team
|
||||
*/
|
||||
export async function addTeamMember(
|
||||
teamId: string,
|
||||
userId: string,
|
||||
role: TTeamRole = 'member'
|
||||
): Promise<TeamMember> {
|
||||
const member = new TeamMember();
|
||||
member.id = await TeamMember.getNewId();
|
||||
member.teamId = teamId;
|
||||
member.userId = userId;
|
||||
member.role = role;
|
||||
member.createdAt = new Date();
|
||||
await member.save();
|
||||
return member;
|
||||
}
|
||||
|
||||
export interface IGrantRepoPermissionOptions {
|
||||
repositoryId: string;
|
||||
userId?: string;
|
||||
teamId?: string;
|
||||
role: TRepositoryRole;
|
||||
grantedById: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant repository permission
|
||||
*/
|
||||
export async function grantRepoPermission(
|
||||
options: IGrantRepoPermissionOptions
|
||||
): Promise<RepositoryPermission> {
|
||||
const perm = new RepositoryPermission();
|
||||
perm.id = await RepositoryPermission.getNewId();
|
||||
perm.repositoryId = options.repositoryId;
|
||||
perm.userId = options.userId;
|
||||
perm.teamId = options.teamId;
|
||||
perm.role = options.role;
|
||||
perm.grantedById = options.grantedById;
|
||||
perm.createdAt = new Date();
|
||||
await perm.save();
|
||||
return perm;
|
||||
}
|
||||
|
||||
export interface ICreateTestPackageOptions {
|
||||
organizationId: string;
|
||||
repositoryId: string;
|
||||
createdById: string;
|
||||
name?: string;
|
||||
protocol?: TRegistryProtocol;
|
||||
versions?: string[];
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test package
|
||||
*/
|
||||
export async function createTestPackage(options: ICreateTestPackageOptions): Promise<Package> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
const protocol = options.protocol || 'npm';
|
||||
const name = options.name || `test-package-${uniqueId}`;
|
||||
|
||||
const pkg = new Package();
|
||||
pkg.id = Package.generateId(protocol, options.organizationId, name);
|
||||
pkg.organizationId = options.organizationId;
|
||||
pkg.repositoryId = options.repositoryId;
|
||||
pkg.protocol = protocol;
|
||||
pkg.name = name;
|
||||
pkg.isPrivate = options.isPrivate ?? true;
|
||||
pkg.createdById = options.createdById;
|
||||
pkg.createdAt = new Date();
|
||||
pkg.updatedAt = new Date();
|
||||
|
||||
const versions = options.versions || ['1.0.0'];
|
||||
for (const version of versions) {
|
||||
pkg.addVersion({
|
||||
version,
|
||||
publishedAt: new Date(),
|
||||
publishedById: options.createdById,
|
||||
size: 1024,
|
||||
digest: `sha256:${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
downloads: 0,
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
|
||||
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||
await pkg.save();
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create complete test scenario with org, repo, team, and package
|
||||
*/
|
||||
export async function createFullTestScenario(ownerId: string): Promise<{
|
||||
organization: Organization;
|
||||
repository: Repository;
|
||||
team: Team;
|
||||
package: Package;
|
||||
}> {
|
||||
const { organization } = await createOrgWithOwner(ownerId);
|
||||
|
||||
const repository = await createTestRepository({
|
||||
organizationId: organization.id,
|
||||
createdById: ownerId,
|
||||
protocol: 'npm',
|
||||
});
|
||||
|
||||
const team = await createTestTeam({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
const pkg = await createTestPackage({
|
||||
organizationId: organization.id,
|
||||
repositoryId: repository.id,
|
||||
createdById: ownerId,
|
||||
});
|
||||
|
||||
return { organization, repository, team, package: pkg };
|
||||
}
|
||||
116
test/helpers/http.helper.ts
Normal file
116
test/helpers/http.helper.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* HTTP test helper - utilities for testing API endpoints
|
||||
*/
|
||||
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
export interface ITestRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ITestResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a test request to the registry API
|
||||
*/
|
||||
export async function testRequest(options: ITestRequest): Promise<ITestResponse> {
|
||||
const baseUrl = testConfig.registry.url;
|
||||
let url = `${baseUrl}${options.path}`;
|
||||
|
||||
if (options.query) {
|
||||
const params = new URLSearchParams(options.query);
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = await response.text();
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
body,
|
||||
headers: response.headers,
|
||||
};
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
export const get = (path: string, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'GET', path, headers });
|
||||
|
||||
export const post = (path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'POST', path, body, headers });
|
||||
|
||||
export const put = (path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'PUT', path, body, headers });
|
||||
|
||||
export const patch = (path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'PATCH', path, body, headers });
|
||||
|
||||
export const del = (path: string, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'DELETE', path, headers });
|
||||
|
||||
/**
|
||||
* Assert response status
|
||||
*/
|
||||
export function assertStatus(response: ITestResponse, expected: number): void {
|
||||
if (response.status !== expected) {
|
||||
throw new Error(
|
||||
`Expected status ${expected} but got ${response.status}: ${JSON.stringify(response.body)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert response body has specific keys
|
||||
*/
|
||||
export function assertBodyHas(response: ITestResponse, keys: string[]): void {
|
||||
const body = response.body as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
if (!(key in body)) {
|
||||
throw new Error(`Expected response to have key "${key}", body: ${JSON.stringify(body)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert response is successful (2xx)
|
||||
*/
|
||||
export function assertSuccess(response: ITestResponse): void {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Expected successful response but got ${response.status}: ${JSON.stringify(response.body)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert response is an error (4xx or 5xx)
|
||||
*/
|
||||
export function assertError(response: ITestResponse, expectedStatus?: number): void {
|
||||
if (response.status < 400) {
|
||||
throw new Error(`Expected error response but got ${response.status}`);
|
||||
}
|
||||
if (expectedStatus !== undefined && response.status !== expectedStatus) {
|
||||
throw new Error(`Expected status ${expectedStatus} but got ${response.status}`);
|
||||
}
|
||||
}
|
||||
85
test/helpers/index.ts
Normal file
85
test/helpers/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Test helpers index - re-exports all helper modules
|
||||
*/
|
||||
|
||||
// Database helpers
|
||||
export {
|
||||
setupTestDb,
|
||||
cleanupTestDb,
|
||||
teardownTestDb,
|
||||
clearCollections,
|
||||
getTestDbName,
|
||||
getTestDb,
|
||||
} from './db.helper.ts';
|
||||
|
||||
// Auth helpers
|
||||
export {
|
||||
createTestUser,
|
||||
createAdminUser,
|
||||
loginUser,
|
||||
createTestApiToken,
|
||||
createAuthHeader,
|
||||
createBasicAuthHeader,
|
||||
getTestPassword,
|
||||
type ICreateTestUserOptions,
|
||||
type ICreateTestApiTokenOptions,
|
||||
} from './auth.helper.ts';
|
||||
|
||||
// Factory helpers
|
||||
export {
|
||||
createTestOrganization,
|
||||
createOrgWithOwner,
|
||||
addOrgMember,
|
||||
createTestRepository,
|
||||
createTestTeam,
|
||||
addTeamMember,
|
||||
grantRepoPermission,
|
||||
createTestPackage,
|
||||
createFullTestScenario,
|
||||
type ICreateTestOrganizationOptions,
|
||||
type ICreateTestRepositoryOptions,
|
||||
type ICreateTestTeamOptions,
|
||||
type IGrantRepoPermissionOptions,
|
||||
type ICreateTestPackageOptions,
|
||||
} from './factory.helper.ts';
|
||||
|
||||
// HTTP helpers
|
||||
export {
|
||||
testRequest,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
patch,
|
||||
del,
|
||||
assertStatus,
|
||||
assertBodyHas,
|
||||
assertSuccess,
|
||||
assertError,
|
||||
type ITestRequest,
|
||||
type ITestResponse,
|
||||
} from './http.helper.ts';
|
||||
|
||||
// Subprocess helpers
|
||||
export {
|
||||
runCommand,
|
||||
commandExists,
|
||||
clients,
|
||||
skipIfMissing,
|
||||
type ICommandResult,
|
||||
type ICommandOptions,
|
||||
} from './subprocess.helper.ts';
|
||||
|
||||
// Storage helpers
|
||||
export {
|
||||
setupTestStorage,
|
||||
checkStorageAvailable,
|
||||
objectExists,
|
||||
listObjects,
|
||||
deleteObject,
|
||||
deletePrefix,
|
||||
cleanupTestStorage,
|
||||
isStorageAvailable,
|
||||
} from './storage.helper.ts';
|
||||
|
||||
// Re-export test config
|
||||
export { testConfig, getTestConfig } from '../test.config.ts';
|
||||
104
test/helpers/storage.helper.ts
Normal file
104
test/helpers/storage.helper.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Storage helper - S3/MinIO verification utilities for tests
|
||||
*
|
||||
* NOTE: These are stub implementations for testing.
|
||||
* The actual smartbucket API should be verified against the real library.
|
||||
*/
|
||||
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
// Storage is optional for unit/integration tests
|
||||
// E2E tests with actual S3 operations would need proper implementation
|
||||
let storageAvailable = false;
|
||||
|
||||
/**
|
||||
* Check if test storage is available
|
||||
*/
|
||||
export async function checkStorageAvailable(): Promise<boolean> {
|
||||
try {
|
||||
// Try to connect to MinIO
|
||||
const response = await fetch(`${testConfig.s3.endpoint}/minio/health/live`, {
|
||||
method: 'GET',
|
||||
});
|
||||
storageAvailable = response.ok;
|
||||
return storageAvailable;
|
||||
} catch {
|
||||
storageAvailable = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test storage connection
|
||||
*/
|
||||
export async function setupTestStorage(): Promise<void> {
|
||||
await checkStorageAvailable();
|
||||
if (storageAvailable) {
|
||||
console.log('[Test Storage] MinIO available at', testConfig.s3.endpoint);
|
||||
} else {
|
||||
console.log('[Test Storage] MinIO not available, storage tests will be skipped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an object exists in storage (stub)
|
||||
*/
|
||||
export async function objectExists(_key: string): Promise<boolean> {
|
||||
if (!storageAvailable) return false;
|
||||
// Would implement actual check here
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* List objects with a given prefix (stub)
|
||||
*/
|
||||
export async function listObjects(_prefix: string): Promise<string[]> {
|
||||
if (!storageAvailable) return [];
|
||||
// Would implement actual list here
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an object from storage (stub)
|
||||
*/
|
||||
export async function deleteObject(_key: string): Promise<void> {
|
||||
if (!storageAvailable) return;
|
||||
// Would implement actual delete here
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all objects with a given prefix
|
||||
*/
|
||||
export async function deletePrefix(prefix: string): Promise<void> {
|
||||
const objects = await listObjects(prefix);
|
||||
for (const key of objects) {
|
||||
await deleteObject(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test storage
|
||||
*/
|
||||
export async function cleanupTestStorage(): Promise<void> {
|
||||
if (!storageAvailable) return;
|
||||
|
||||
try {
|
||||
// Delete all test objects
|
||||
await deletePrefix('npm/');
|
||||
await deletePrefix('oci/');
|
||||
await deletePrefix('maven/');
|
||||
await deletePrefix('cargo/');
|
||||
await deletePrefix('pypi/');
|
||||
await deletePrefix('composer/');
|
||||
await deletePrefix('rubygems/');
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage is available
|
||||
*/
|
||||
export function isStorageAvailable(): boolean {
|
||||
return storageAvailable;
|
||||
}
|
||||
208
test/helpers/subprocess.helper.ts
Normal file
208
test/helpers/subprocess.helper.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Subprocess helper - utilities for running protocol clients in tests
|
||||
*/
|
||||
|
||||
export interface ICommandResult {
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
signal?: Deno.Signal;
|
||||
}
|
||||
|
||||
export interface ICommandOptions {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout?: number;
|
||||
stdin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return the result
|
||||
*/
|
||||
export async function runCommand(
|
||||
cmd: string[],
|
||||
options: ICommandOptions = {}
|
||||
): Promise<ICommandResult> {
|
||||
const { cwd, env, timeout = 60000, stdin } = options;
|
||||
|
||||
const command = new Deno.Command(cmd[0], {
|
||||
args: cmd.slice(1),
|
||||
cwd,
|
||||
env: { ...Deno.env.toObject(), ...env },
|
||||
stdin: stdin ? 'piped' : 'null',
|
||||
stdout: 'piped',
|
||||
stderr: 'piped',
|
||||
});
|
||||
|
||||
const child = command.spawn();
|
||||
|
||||
if (stdin && child.stdin) {
|
||||
const writer = child.stdin.getWriter();
|
||||
await writer.write(new TextEncoder().encode(stdin));
|
||||
await writer.close();
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
const output = await child.output();
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
return {
|
||||
success: output.success,
|
||||
stdout: new TextDecoder().decode(output.stdout),
|
||||
stderr: new TextDecoder().decode(output.stderr),
|
||||
code: output.code,
|
||||
signal: output.signal ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is available
|
||||
*/
|
||||
export async function commandExists(cmd: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await runCommand(['which', cmd], { timeout: 5000 });
|
||||
return result.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol client wrappers
|
||||
*/
|
||||
export const clients = {
|
||||
npm: {
|
||||
check: () => commandExists('npm'),
|
||||
publish: (dir: string, registry: string, token: string) =>
|
||||
runCommand(['npm', 'publish', '--registry', registry], {
|
||||
cwd: dir,
|
||||
env: { NPM_TOKEN: token, npm_config__authToken: token },
|
||||
}),
|
||||
install: (pkg: string, registry: string, dir: string) =>
|
||||
runCommand(['npm', 'install', pkg, '--registry', registry], { cwd: dir }),
|
||||
unpublish: (pkg: string, registry: string, token: string) =>
|
||||
runCommand(['npm', 'unpublish', pkg, '--registry', registry, '--force'], {
|
||||
env: { NPM_TOKEN: token, npm_config__authToken: token },
|
||||
}),
|
||||
pack: (dir: string) => runCommand(['npm', 'pack'], { cwd: dir }),
|
||||
},
|
||||
|
||||
docker: {
|
||||
check: () => commandExists('docker'),
|
||||
build: (dockerfile: string, tag: string, context: string) =>
|
||||
runCommand(['docker', 'build', '-f', dockerfile, '-t', tag, context]),
|
||||
push: (image: string) => runCommand(['docker', 'push', image]),
|
||||
pull: (image: string) => runCommand(['docker', 'pull', image]),
|
||||
rmi: (image: string, force = false) =>
|
||||
runCommand(['docker', 'rmi', ...(force ? ['-f'] : []), image]),
|
||||
login: (registry: string, username: string, password: string) =>
|
||||
runCommand(['docker', 'login', registry, '-u', username, '--password-stdin'], {
|
||||
stdin: password,
|
||||
}),
|
||||
tag: (source: string, target: string) => runCommand(['docker', 'tag', source, target]),
|
||||
},
|
||||
|
||||
cargo: {
|
||||
check: () => commandExists('cargo'),
|
||||
package: (dir: string) => runCommand(['cargo', 'package', '--allow-dirty'], { cwd: dir }),
|
||||
publish: (dir: string, registry: string, token: string) =>
|
||||
runCommand(
|
||||
['cargo', 'publish', '--registry', 'stack-test', '--token', token, '--allow-dirty'],
|
||||
{ cwd: dir }
|
||||
),
|
||||
yank: (crate: string, version: string, token: string) =>
|
||||
runCommand([
|
||||
'cargo',
|
||||
'yank',
|
||||
crate,
|
||||
'--version',
|
||||
version,
|
||||
'--registry',
|
||||
'stack-test',
|
||||
'--token',
|
||||
token,
|
||||
]),
|
||||
},
|
||||
|
||||
pip: {
|
||||
check: () => commandExists('pip'),
|
||||
build: (dir: string) => runCommand(['python', '-m', 'build', dir]),
|
||||
upload: (dist: string, repository: string, token: string) =>
|
||||
runCommand([
|
||||
'python',
|
||||
'-m',
|
||||
'twine',
|
||||
'upload',
|
||||
'--repository-url',
|
||||
repository,
|
||||
'-u',
|
||||
'__token__',
|
||||
'-p',
|
||||
token,
|
||||
`${dist}/*`,
|
||||
]),
|
||||
install: (pkg: string, indexUrl: string) =>
|
||||
runCommand(['pip', 'install', pkg, '--index-url', indexUrl]),
|
||||
},
|
||||
|
||||
composer: {
|
||||
check: () => commandExists('composer'),
|
||||
install: (pkg: string, repository: string, dir: string) =>
|
||||
runCommand(
|
||||
[
|
||||
'composer',
|
||||
'require',
|
||||
pkg,
|
||||
'--repository',
|
||||
JSON.stringify({ type: 'composer', url: repository }),
|
||||
],
|
||||
{ cwd: dir }
|
||||
),
|
||||
},
|
||||
|
||||
gem: {
|
||||
check: () => commandExists('gem'),
|
||||
build: (gemspec: string, dir: string) => runCommand(['gem', 'build', gemspec], { cwd: dir }),
|
||||
push: (gemFile: string, host: string, key: string) =>
|
||||
runCommand(['gem', 'push', gemFile, '--host', host, '--key', key]),
|
||||
install: (gemName: string, source: string) =>
|
||||
runCommand(['gem', 'install', gemName, '--source', source]),
|
||||
yank: (gemName: string, version: string, host: string, key: string) =>
|
||||
runCommand(['gem', 'yank', gemName, '-v', version, '--host', host, '--key', key]),
|
||||
},
|
||||
|
||||
maven: {
|
||||
check: () => commandExists('mvn'),
|
||||
deploy: (dir: string, repositoryUrl: string, username: string, password: string) =>
|
||||
runCommand(
|
||||
[
|
||||
'mvn',
|
||||
'deploy',
|
||||
`-DaltDeploymentRepository=stack-test::default::${repositoryUrl}`,
|
||||
`-Dusername=${username}`,
|
||||
`-Dpassword=${password}`,
|
||||
],
|
||||
{ cwd: dir }
|
||||
),
|
||||
package: (dir: string) => runCommand(['mvn', 'package', '-DskipTests'], { cwd: dir }),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip test if command is not available
|
||||
*/
|
||||
export async function skipIfMissing(cmd: string): Promise<boolean> {
|
||||
const exists = await commandExists(cmd);
|
||||
if (!exists) {
|
||||
console.warn(`[Skip] ${cmd} not available`);
|
||||
}
|
||||
return !exists;
|
||||
}
|
||||
169
test/integration/auth.test.ts
Normal file
169
test/integration/auth.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Authentication integration tests
|
||||
* Tests the full authentication flow through the API
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
post,
|
||||
get,
|
||||
assertStatus,
|
||||
createAuthHeader,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
describe('Auth API Integration', () => {
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/login', () => {
|
||||
it('should login with valid credentials', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'api-login@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const response = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertExists(body.accessToken);
|
||||
assertExists(body.refreshToken);
|
||||
assertExists(body.user);
|
||||
});
|
||||
|
||||
it('should return 401 for invalid credentials', async () => {
|
||||
const response = await post('/api/v1/auth/login', {
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'wrongpassword',
|
||||
});
|
||||
|
||||
assertStatus(response, 401);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.error, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('should return 401 for inactive user', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'suspended@example.com',
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
const response = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
assertStatus(response, 401);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.error, 'ACCOUNT_INACTIVE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/refresh', () => {
|
||||
it('should refresh access token', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'refresh@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Login first
|
||||
const loginResponse = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
const loginBody = loginResponse.body as Record<string, unknown>;
|
||||
|
||||
// Refresh
|
||||
const refreshResponse = await post('/api/v1/auth/refresh', {
|
||||
refreshToken: loginBody.refreshToken,
|
||||
});
|
||||
|
||||
assertStatus(refreshResponse, 200);
|
||||
const refreshBody = refreshResponse.body as Record<string, unknown>;
|
||||
assertExists(refreshBody.accessToken);
|
||||
});
|
||||
|
||||
it('should return 401 for invalid refresh token', async () => {
|
||||
const response = await post('/api/v1/auth/refresh', {
|
||||
refreshToken: 'invalid-token',
|
||||
});
|
||||
|
||||
assertStatus(response, 401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/auth/me', () => {
|
||||
it('should return current user info', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'me@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Login
|
||||
const loginResponse = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
const loginBody = loginResponse.body as Record<string, unknown>;
|
||||
|
||||
// Get current user
|
||||
const meResponse = await get(
|
||||
'/api/v1/auth/me',
|
||||
createAuthHeader(loginBody.accessToken as string)
|
||||
);
|
||||
|
||||
assertStatus(meResponse, 200);
|
||||
const meBody = meResponse.body as Record<string, unknown>;
|
||||
assertEquals(meBody.email, user.email);
|
||||
});
|
||||
|
||||
it('should return 401 without token', async () => {
|
||||
const response = await get('/api/v1/auth/me');
|
||||
|
||||
assertStatus(response, 401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/logout', () => {
|
||||
it('should invalidate session', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'logout@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Login
|
||||
const loginResponse = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
const loginBody = loginResponse.body as Record<string, unknown>;
|
||||
const token = loginBody.accessToken as string;
|
||||
|
||||
// Logout
|
||||
const logoutResponse = await post('/api/v1/auth/logout', {}, createAuthHeader(token));
|
||||
|
||||
assertStatus(logoutResponse, 200);
|
||||
|
||||
// Token should no longer work
|
||||
const meResponse = await get('/api/v1/auth/me', createAuthHeader(token));
|
||||
assertStatus(meResponse, 401);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
test/integration/organization.test.ts
Normal file
228
test/integration/organization.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Organization integration tests
|
||||
* Tests organization CRUD and member management through the API
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
loginUser,
|
||||
post,
|
||||
get,
|
||||
put,
|
||||
del,
|
||||
assertStatus,
|
||||
createAuthHeader,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
describe('Organization API Integration', () => {
|
||||
let accessToken: string;
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user, password } = await createTestUser({ status: 'active' });
|
||||
testUserId = user.id;
|
||||
const tokens = await loginUser(user.email, password);
|
||||
accessToken = tokens.accessToken;
|
||||
});
|
||||
|
||||
describe('POST /api/v1/organizations', () => {
|
||||
it('should create organization', async () => {
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{
|
||||
name: 'my-org',
|
||||
displayName: 'My Organization',
|
||||
description: 'A test organization',
|
||||
},
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 201);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.name, 'my-org');
|
||||
assertEquals(body.displayName, 'My Organization');
|
||||
});
|
||||
|
||||
it('should create organization with dots in name', async () => {
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{
|
||||
name: 'push.rocks',
|
||||
displayName: 'Push Rocks',
|
||||
},
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 201);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.name, 'push.rocks');
|
||||
});
|
||||
|
||||
it('should reject duplicate org name', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'duplicate', displayName: 'First' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'duplicate', displayName: 'Second' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 409);
|
||||
});
|
||||
|
||||
it('should reject invalid org name', async () => {
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: '.invalid', displayName: 'Invalid' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/organizations', () => {
|
||||
it('should list user organizations', async () => {
|
||||
// Create some organizations
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'org1', displayName: 'Org 1' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'org2', displayName: 'Org 2' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await get('/api/v1/organizations', createAuthHeader(accessToken));
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>[];
|
||||
assertEquals(body.length >= 2, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/organizations/:orgName', () => {
|
||||
it('should get organization by name', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'get-me', displayName: 'Get Me' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await get('/api/v1/organizations/get-me', createAuthHeader(accessToken));
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.name, 'get-me');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent org', async () => {
|
||||
const response = await get(
|
||||
'/api/v1/organizations/non-existent',
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/v1/organizations/:orgName', () => {
|
||||
it('should update organization', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'update-me', displayName: 'Original' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await put(
|
||||
'/api/v1/organizations/update-me',
|
||||
{ displayName: 'Updated', description: 'New description' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.displayName, 'Updated');
|
||||
assertEquals(body.description, 'New description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/organizations/:orgName', () => {
|
||||
it('should delete organization', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'delete-me', displayName: 'Delete Me' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await del('/api/v1/organizations/delete-me', createAuthHeader(accessToken));
|
||||
|
||||
assertStatus(response, 200);
|
||||
|
||||
// Verify deleted
|
||||
const getResponse = await get(
|
||||
'/api/v1/organizations/delete-me',
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
assertStatus(getResponse, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Organization Members', () => {
|
||||
it('should list organization members', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'members-org', displayName: 'Members Org' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await get(
|
||||
'/api/v1/organizations/members-org/members',
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>[];
|
||||
assertEquals(body.length >= 1, true); // At least the creator
|
||||
});
|
||||
|
||||
it('should add member to organization', async () => {
|
||||
// Create another user
|
||||
const { user: newUser } = await createTestUser({ email: 'newmember@example.com' });
|
||||
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'add-member-org', displayName: 'Add Member Org' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await post(
|
||||
'/api/v1/organizations/add-member-org/members',
|
||||
{ userId: newUser.id, role: 'member' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 201);
|
||||
});
|
||||
});
|
||||
});
|
||||
60
test/test.config.ts
Normal file
60
test/test.config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Test configuration for Stack.Gallery Registry tests
|
||||
*/
|
||||
|
||||
export const testConfig = {
|
||||
mongodb: {
|
||||
url: 'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin',
|
||||
name: 'test-registry',
|
||||
},
|
||||
s3: {
|
||||
endpoint: 'http://localhost:9100',
|
||||
accessKey: 'testadmin',
|
||||
secretKey: 'testpassword',
|
||||
bucket: 'test-registry',
|
||||
region: 'us-east-1',
|
||||
},
|
||||
jwt: {
|
||||
secret: 'test-jwt-secret-for-testing-only',
|
||||
refreshSecret: 'test-refresh-secret-for-testing-only',
|
||||
},
|
||||
registry: {
|
||||
url: 'http://localhost:3000',
|
||||
port: 3000,
|
||||
},
|
||||
testUser: {
|
||||
email: 'test@stack.gallery',
|
||||
password: 'TestPassword123!',
|
||||
username: 'testuser',
|
||||
},
|
||||
adminUser: {
|
||||
email: 'admin@stack.gallery',
|
||||
password: 'admin',
|
||||
username: 'admin',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get test config with environment variable overrides
|
||||
*/
|
||||
export function getTestConfig() {
|
||||
return {
|
||||
...testConfig,
|
||||
mongodb: {
|
||||
...testConfig.mongodb,
|
||||
url: Deno.env.get('TEST_MONGODB_URL') || testConfig.mongodb.url,
|
||||
name: Deno.env.get('TEST_MONGODB_NAME') || testConfig.mongodb.name,
|
||||
},
|
||||
s3: {
|
||||
...testConfig.s3,
|
||||
endpoint: Deno.env.get('TEST_S3_ENDPOINT') || testConfig.s3.endpoint,
|
||||
accessKey: Deno.env.get('TEST_S3_ACCESS_KEY') || testConfig.s3.accessKey,
|
||||
secretKey: Deno.env.get('TEST_S3_SECRET_KEY') || testConfig.s3.secretKey,
|
||||
bucket: Deno.env.get('TEST_S3_BUCKET') || testConfig.s3.bucket,
|
||||
},
|
||||
registry: {
|
||||
...testConfig.registry,
|
||||
url: Deno.env.get('TEST_REGISTRY_URL') || testConfig.registry.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
232
test/unit/models/apitoken.test.ts
Normal file
232
test/unit/models/apitoken.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* ApiToken model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||
|
||||
describe('ApiToken Model', () => {
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
async function createToken(overrides: Partial<ApiToken> = {}): Promise<ApiToken> {
|
||||
const token = new ApiToken();
|
||||
token.id = await ApiToken.getNewId();
|
||||
token.userId = overrides.userId || testUserId;
|
||||
token.name = overrides.name || 'test-token';
|
||||
token.tokenHash = overrides.tokenHash || `hash-${crypto.randomUUID()}`;
|
||||
token.tokenPrefix = overrides.tokenPrefix || 'srg_test';
|
||||
token.protocols = overrides.protocols || ['npm', 'oci'];
|
||||
token.scopes = overrides.scopes || [{ protocol: '*', actions: ['read', 'write'] }];
|
||||
token.createdAt = new Date();
|
||||
|
||||
if (overrides.expiresAt) token.expiresAt = overrides.expiresAt;
|
||||
if (overrides.isRevoked) token.isRevoked = overrides.isRevoked;
|
||||
if (overrides.organizationId) token.organizationId = overrides.organizationId;
|
||||
|
||||
await token.save();
|
||||
return token;
|
||||
}
|
||||
|
||||
describe('findByHash', () => {
|
||||
it('should find token by hash', async () => {
|
||||
const created = await createToken({ tokenHash: 'unique-hash-123' });
|
||||
|
||||
const found = await ApiToken.findByHash('unique-hash-123');
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
|
||||
it('should not find revoked tokens', async () => {
|
||||
await createToken({
|
||||
tokenHash: 'revoked-hash',
|
||||
isRevoked: true,
|
||||
});
|
||||
|
||||
const found = await ApiToken.findByHash('revoked-hash');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserTokens', () => {
|
||||
it('should return all user tokens', async () => {
|
||||
await createToken({ name: 'token1' });
|
||||
await createToken({ name: 'token2' });
|
||||
|
||||
const tokens = await ApiToken.getUserTokens(testUserId);
|
||||
assertEquals(tokens.length, 2);
|
||||
});
|
||||
|
||||
it('should not return revoked tokens', async () => {
|
||||
await createToken({ name: 'active' });
|
||||
await createToken({ name: 'revoked', isRevoked: true });
|
||||
|
||||
const tokens = await ApiToken.getUserTokens(testUserId);
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].name, 'active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgTokens', () => {
|
||||
it('should return organization tokens', async () => {
|
||||
const orgId = 'org-123';
|
||||
await createToken({ name: 'org-token', organizationId: orgId });
|
||||
await createToken({ name: 'personal-token' }); // No org
|
||||
|
||||
const tokens = await ApiToken.getOrgTokens(orgId);
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].name, 'org-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValid', () => {
|
||||
it('should return true for valid token', async () => {
|
||||
const token = await createToken();
|
||||
assertEquals(token.isValid(), true);
|
||||
});
|
||||
|
||||
it('should return false for revoked token', async () => {
|
||||
const token = await createToken({ isRevoked: true });
|
||||
assertEquals(token.isValid(), false);
|
||||
});
|
||||
|
||||
it('should return false for expired token', async () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 1);
|
||||
|
||||
const token = await createToken({ expiresAt: pastDate });
|
||||
assertEquals(token.isValid(), false);
|
||||
});
|
||||
|
||||
it('should return true for non-expired token', async () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
|
||||
const token = await createToken({ expiresAt: futureDate });
|
||||
assertEquals(token.isValid(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordUsage', () => {
|
||||
it('should update usage stats', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.recordUsage('192.168.1.1');
|
||||
|
||||
assertExists(token.lastUsedAt);
|
||||
assertEquals(token.lastUsedIp, '192.168.1.1');
|
||||
assertEquals(token.usageCount, 1);
|
||||
});
|
||||
|
||||
it('should increment usage count', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.recordUsage();
|
||||
await token.recordUsage();
|
||||
await token.recordUsage();
|
||||
|
||||
assertEquals(token.usageCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke token with reason', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.revoke('Security concern');
|
||||
|
||||
assertEquals(token.isRevoked, true);
|
||||
assertExists(token.revokedAt);
|
||||
assertEquals(token.revokedReason, 'Security concern');
|
||||
});
|
||||
|
||||
it('should revoke token without reason', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.revoke();
|
||||
|
||||
assertEquals(token.isRevoked, true);
|
||||
assertExists(token.revokedAt);
|
||||
assertEquals(token.revokedReason, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasProtocol', () => {
|
||||
it('should return true for allowed protocol', async () => {
|
||||
const token = await createToken({ protocols: ['npm', 'oci'] });
|
||||
|
||||
assertEquals(token.hasProtocol('npm'), true);
|
||||
assertEquals(token.hasProtocol('oci'), true);
|
||||
});
|
||||
|
||||
it('should return false for disallowed protocol', async () => {
|
||||
const token = await createToken({ protocols: ['npm'] });
|
||||
|
||||
assertEquals(token.hasProtocol('maven'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasScope', () => {
|
||||
it('should allow wildcard protocol scope', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', actions: ['read', 'write'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm'), true);
|
||||
assertEquals(token.hasScope('oci'), true);
|
||||
assertEquals(token.hasScope('maven'), true);
|
||||
});
|
||||
|
||||
it('should restrict by specific protocol', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm'), true);
|
||||
assertEquals(token.hasScope('oci'), false);
|
||||
});
|
||||
|
||||
it('should restrict by organization', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', organizationId: 'org-123', actions: ['read'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm', 'org-123'), true);
|
||||
assertEquals(token.hasScope('npm', 'org-456'), false);
|
||||
});
|
||||
|
||||
it('should check action permissions', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', actions: ['read'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true);
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'write'), false);
|
||||
});
|
||||
|
||||
it('should allow wildcard action', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', actions: ['*'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true);
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'write'), true);
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'delete'), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
220
test/unit/models/organization.test.ts
Normal file
220
test/unit/models/organization.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Organization model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { Organization } from '../../../ts/models/organization.ts';
|
||||
|
||||
describe('Organization Model', () => {
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
describe('createOrganization', () => {
|
||||
it('should create an organization with valid data', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'test-org',
|
||||
displayName: 'Test Organization',
|
||||
description: 'A test organization',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertExists(org.id);
|
||||
assertEquals(org.name, 'test-org');
|
||||
assertEquals(org.displayName, 'Test Organization');
|
||||
assertEquals(org.description, 'A test organization');
|
||||
assertEquals(org.createdById, testUserId);
|
||||
assertEquals(org.isPublic, false);
|
||||
assertEquals(org.memberCount, 0);
|
||||
assertEquals(org.plan, 'free');
|
||||
});
|
||||
|
||||
it('should allow dots in org name (domain-like)', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'push.rocks',
|
||||
displayName: 'Push Rocks',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.name, 'push.rocks');
|
||||
});
|
||||
|
||||
it('should allow hyphens in org name', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'my-awesome-org',
|
||||
displayName: 'My Awesome Org',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.name, 'my-awesome-org');
|
||||
});
|
||||
|
||||
it('should reject uppercase names (must be lowercase)', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'UPPERCASE',
|
||||
displayName: 'Uppercase Org',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid names starting with dot', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: '.invalid',
|
||||
displayName: 'Invalid',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid names ending with dot', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'invalid.',
|
||||
displayName: 'Invalid',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject names with special characters', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'invalid@org',
|
||||
displayName: 'Invalid',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should set default settings', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'defaults',
|
||||
displayName: 'Defaults Test',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.settings.requireMfa, false);
|
||||
assertEquals(org.settings.allowPublicRepositories, true);
|
||||
assertEquals(org.settings.defaultRepositoryVisibility, 'private');
|
||||
assertEquals(org.settings.allowedProtocols.length, 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find organization by ID', async () => {
|
||||
const created = await Organization.createOrganization({
|
||||
name: 'findable',
|
||||
displayName: 'Findable Org',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Organization.findById(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
|
||||
it('should return null for non-existent ID', async () => {
|
||||
const found = await Organization.findById('non-existent-id');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find organization by name (case-insensitive)', async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'byname',
|
||||
displayName: 'By Name',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Organization.findByName('BYNAME');
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'byname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage quota', () => {
|
||||
it('should have default 5GB quota', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'quota-test',
|
||||
displayName: 'Quota Test',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.storageQuotaBytes, 5 * 1024 * 1024 * 1024);
|
||||
assertEquals(org.usedStorageBytes, 0);
|
||||
});
|
||||
|
||||
it('should check available storage', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'storage-check',
|
||||
displayName: 'Storage Check',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.hasStorageAvailable(1024), true);
|
||||
assertEquals(org.hasStorageAvailable(6 * 1024 * 1024 * 1024), false);
|
||||
});
|
||||
|
||||
it('should allow unlimited storage with -1 quota', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'unlimited',
|
||||
displayName: 'Unlimited',
|
||||
createdById: testUserId,
|
||||
});
|
||||
org.storageQuotaBytes = -1;
|
||||
|
||||
assertEquals(org.hasStorageAvailable(1000 * 1024 * 1024 * 1024), true);
|
||||
});
|
||||
|
||||
it('should update storage usage', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'usage-test',
|
||||
displayName: 'Usage Test',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
await org.updateStorageUsage(1000);
|
||||
assertEquals(org.usedStorageBytes, 1000);
|
||||
|
||||
await org.updateStorageUsage(500);
|
||||
assertEquals(org.usedStorageBytes, 1500);
|
||||
|
||||
await org.updateStorageUsage(-2000);
|
||||
assertEquals(org.usedStorageBytes, 0); // Should not go negative
|
||||
});
|
||||
});
|
||||
});
|
||||
240
test/unit/models/package.test.ts
Normal file
240
test/unit/models/package.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Package model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
} from '../../helpers/index.ts';
|
||||
import { Package } from '../../../ts/models/package.ts';
|
||||
import type { IPackageVersion } from '../../../ts/interfaces/package.interfaces.ts';
|
||||
|
||||
describe('Package Model', () => {
|
||||
let testUserId: string;
|
||||
let testOrgId: string;
|
||||
let testRepoId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
const { organization } = await createOrgWithOwner(testUserId);
|
||||
testOrgId = organization.id;
|
||||
const repo = await createTestRepository({
|
||||
organizationId: testOrgId,
|
||||
createdById: testUserId,
|
||||
protocol: 'npm',
|
||||
});
|
||||
testRepoId = repo.id;
|
||||
});
|
||||
|
||||
function createVersion(version: string): IPackageVersion {
|
||||
return {
|
||||
version,
|
||||
publishedAt: new Date(),
|
||||
publishedBy: testUserId,
|
||||
size: 1024,
|
||||
checksum: `sha256-${crypto.randomUUID()}`,
|
||||
checksumAlgorithm: 'sha256',
|
||||
downloads: 0,
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function createPackage(name: string, versions: string[] = ['1.0.0']): Promise<Package> {
|
||||
const pkg = new Package();
|
||||
pkg.id = Package.generateId('npm', testOrgId, name);
|
||||
pkg.organizationId = testOrgId;
|
||||
pkg.repositoryId = testRepoId;
|
||||
pkg.protocol = 'npm';
|
||||
pkg.name = name;
|
||||
pkg.createdById = testUserId;
|
||||
pkg.createdAt = new Date();
|
||||
pkg.updatedAt = new Date();
|
||||
|
||||
for (const v of versions) {
|
||||
pkg.addVersion(createVersion(v));
|
||||
}
|
||||
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||
|
||||
await pkg.save();
|
||||
return pkg;
|
||||
}
|
||||
|
||||
describe('generateId', () => {
|
||||
it('should generate correct format', () => {
|
||||
const id = Package.generateId('npm', 'my-org', 'my-package');
|
||||
assertEquals(id, 'npm:my-org:my-package');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find package by ID', async () => {
|
||||
const created = await createPackage('findable');
|
||||
|
||||
const found = await Package.findById(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'findable');
|
||||
});
|
||||
|
||||
it('should return null for non-existent ID', async () => {
|
||||
const found = await Package.findById('npm:fake:package');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find package by protocol, org, and name', async () => {
|
||||
await createPackage('by-name');
|
||||
|
||||
const found = await Package.findByName('npm', testOrgId, 'by-name');
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'by-name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgPackages', () => {
|
||||
it('should return all packages in organization', async () => {
|
||||
await createPackage('pkg1');
|
||||
await createPackage('pkg2');
|
||||
await createPackage('pkg3');
|
||||
|
||||
const packages = await Package.getOrgPackages(testOrgId);
|
||||
assertEquals(packages.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should find packages by name', async () => {
|
||||
await createPackage('search-me');
|
||||
await createPackage('find-this');
|
||||
await createPackage('other');
|
||||
|
||||
const results = await Package.search('search');
|
||||
assertEquals(results.length, 1);
|
||||
assertEquals(results[0].name, 'search-me');
|
||||
});
|
||||
|
||||
it('should find packages by description', async () => {
|
||||
const pkg = await createPackage('described');
|
||||
pkg.description = 'A unique description for testing';
|
||||
await pkg.save();
|
||||
|
||||
const results = await Package.search('unique description');
|
||||
assertEquals(results.length, 1);
|
||||
});
|
||||
|
||||
it('should filter by protocol', async () => {
|
||||
await createPackage('npm-pkg');
|
||||
|
||||
const results = await Package.search('npm', { protocol: 'oci' });
|
||||
assertEquals(results.length, 0);
|
||||
});
|
||||
|
||||
it('should apply pagination', async () => {
|
||||
await createPackage('page1');
|
||||
await createPackage('page2');
|
||||
await createPackage('page3');
|
||||
|
||||
const firstPage = await Package.search('page', { limit: 2, offset: 0 });
|
||||
assertEquals(firstPage.length, 2);
|
||||
|
||||
const secondPage = await Package.search('page', { limit: 2, offset: 2 });
|
||||
assertEquals(secondPage.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('versions', () => {
|
||||
it('should add version and update storage', async () => {
|
||||
const pkg = await createPackage('versioned', []);
|
||||
|
||||
pkg.addVersion(createVersion('1.0.0'));
|
||||
|
||||
assertEquals(Object.keys(pkg.versions).length, 1);
|
||||
assertEquals(pkg.storageBytes, 1024);
|
||||
});
|
||||
|
||||
it('should get specific version', async () => {
|
||||
const pkg = await createPackage('multi-version', ['1.0.0', '1.1.0', '2.0.0']);
|
||||
|
||||
const v1 = pkg.getVersion('1.0.0');
|
||||
assertExists(v1);
|
||||
assertEquals(v1.version, '1.0.0');
|
||||
|
||||
const v2 = pkg.getVersion('2.0.0');
|
||||
assertExists(v2);
|
||||
assertEquals(v2.version, '2.0.0');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent version', async () => {
|
||||
const pkg = await createPackage('single', ['1.0.0']);
|
||||
|
||||
const missing = pkg.getVersion('9.9.9');
|
||||
assertEquals(missing, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestVersion', () => {
|
||||
it('should return version from distTags.latest', async () => {
|
||||
const pkg = await createPackage('tagged', ['1.0.0', '2.0.0']);
|
||||
pkg.distTags['latest'] = '1.0.0'; // Set older version as latest
|
||||
await pkg.save();
|
||||
|
||||
const latest = pkg.getLatestVersion();
|
||||
assertExists(latest);
|
||||
assertEquals(latest.version, '1.0.0');
|
||||
});
|
||||
|
||||
it('should fallback to last version if no latest tag', async () => {
|
||||
const pkg = await createPackage('untagged', ['1.0.0', '2.0.0']);
|
||||
delete pkg.distTags['latest'];
|
||||
|
||||
const latest = pkg.getLatestVersion();
|
||||
assertExists(latest);
|
||||
assertEquals(latest.version, '2.0.0');
|
||||
});
|
||||
|
||||
it('should return undefined for empty versions', async () => {
|
||||
const pkg = await createPackage('empty', []);
|
||||
delete pkg.distTags['latest'];
|
||||
|
||||
const latest = pkg.getLatestVersion();
|
||||
assertEquals(latest, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementDownloads', () => {
|
||||
it('should increment total download count', async () => {
|
||||
const pkg = await createPackage('downloads');
|
||||
|
||||
await pkg.incrementDownloads();
|
||||
assertEquals(pkg.downloadCount, 1);
|
||||
|
||||
await pkg.incrementDownloads();
|
||||
await pkg.incrementDownloads();
|
||||
assertEquals(pkg.downloadCount, 3);
|
||||
});
|
||||
|
||||
it('should increment version-specific downloads', async () => {
|
||||
const pkg = await createPackage('version-downloads', ['1.0.0', '2.0.0']);
|
||||
|
||||
await pkg.incrementDownloads('1.0.0');
|
||||
assertEquals(pkg.versions['1.0.0'].downloads, 1);
|
||||
assertEquals(pkg.versions['2.0.0'].downloads, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
285
test/unit/models/repository.test.ts
Normal file
285
test/unit/models/repository.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Repository model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
} from '../../helpers/index.ts';
|
||||
import { Repository } from '../../../ts/models/repository.ts';
|
||||
|
||||
describe('Repository Model', () => {
|
||||
let testUserId: string;
|
||||
let testOrgId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
const { organization } = await createOrgWithOwner(testUserId);
|
||||
testOrgId = organization.id;
|
||||
});
|
||||
|
||||
describe('createRepository', () => {
|
||||
it('should create a repository with valid data', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'test-repo',
|
||||
description: 'A test repository',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertExists(repo.id);
|
||||
assertEquals(repo.name, 'test-repo');
|
||||
assertEquals(repo.organizationId, testOrgId);
|
||||
assertEquals(repo.protocol, 'npm');
|
||||
assertEquals(repo.visibility, 'private');
|
||||
assertEquals(repo.downloadCount, 0);
|
||||
assertEquals(repo.starCount, 0);
|
||||
});
|
||||
|
||||
it('should allow dots and underscores in name', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'my.test_repo',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.name, 'my.test_repo');
|
||||
});
|
||||
|
||||
it('should lowercase the name', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'UPPERCASE',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.name, 'uppercase');
|
||||
});
|
||||
|
||||
it('should set correct storage namespace', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'packages',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.storageNamespace, `npm/${testOrgId}/packages`);
|
||||
});
|
||||
|
||||
it('should reject duplicate name+protocol in same org', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'unique',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'unique',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'already exists'
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow same name with different protocol', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'packages',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const ociRepo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'packages',
|
||||
protocol: 'oci',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(ociRepo.name, 'packages');
|
||||
assertEquals(ociRepo.protocol, 'oci');
|
||||
});
|
||||
|
||||
it('should reject invalid names', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: '-invalid',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should set visibility when provided', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'public-repo',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.visibility, 'public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find repository by org, name, and protocol', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'findable',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Repository.findByName(testOrgId, 'FINDABLE', 'npm');
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'findable');
|
||||
});
|
||||
|
||||
it('should return null for wrong protocol', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'npm-only',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Repository.findByName(testOrgId, 'npm-only', 'oci');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgRepositories', () => {
|
||||
it('should return all org repositories', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'repo1',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'repo2',
|
||||
protocol: 'oci',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'repo3',
|
||||
protocol: 'maven',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const repos = await Repository.getOrgRepositories(testOrgId);
|
||||
assertEquals(repos.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicRepositories', () => {
|
||||
it('should return only public repositories', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'public1',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'private1',
|
||||
protocol: 'npm',
|
||||
visibility: 'private',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const repos = await Repository.getPublicRepositories();
|
||||
assertEquals(repos.length, 1);
|
||||
assertEquals(repos[0].name, 'public1');
|
||||
});
|
||||
|
||||
it('should filter by protocol when provided', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'npm-public',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'oci-public',
|
||||
protocol: 'oci',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const repos = await Repository.getPublicRepositories('npm');
|
||||
assertEquals(repos.length, 1);
|
||||
assertEquals(repos[0].protocol, 'npm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementDownloads', () => {
|
||||
it('should increment download count', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'downloads',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
await repo.incrementDownloads();
|
||||
assertEquals(repo.downloadCount, 1);
|
||||
|
||||
await repo.incrementDownloads();
|
||||
await repo.incrementDownloads();
|
||||
assertEquals(repo.downloadCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFullPath', () => {
|
||||
it('should return org/repo path', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'my-package',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const path = repo.getFullPath('my-org');
|
||||
assertEquals(path, 'my-org/my-package');
|
||||
});
|
||||
});
|
||||
});
|
||||
142
test/unit/models/session.test.ts
Normal file
142
test/unit/models/session.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Session model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { Session } from '../../../ts/models/session.ts';
|
||||
|
||||
describe('Session Model', () => {
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a session with valid data', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipAddress: '192.168.1.1',
|
||||
});
|
||||
|
||||
assertExists(session.id);
|
||||
assertEquals(session.userId, testUserId);
|
||||
assertEquals(session.userAgent, 'Mozilla/5.0');
|
||||
assertEquals(session.ipAddress, '192.168.1.1');
|
||||
assertEquals(session.isValid, true);
|
||||
assertExists(session.createdAt);
|
||||
assertExists(session.lastActivityAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findValidSession', () => {
|
||||
it('should find valid session by ID', async () => {
|
||||
const created = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test Agent',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
const found = await Session.findValidSession(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
|
||||
it('should not find invalidated session', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test Agent',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
await session.invalidate('Logged out');
|
||||
|
||||
const found = await Session.findValidSession(session.id);
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserSessions', () => {
|
||||
it('should return all valid sessions for user', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
||||
|
||||
const sessions = await Session.getUserSessions(testUserId);
|
||||
assertEquals(sessions.length, 3);
|
||||
});
|
||||
|
||||
it('should not return invalidated sessions', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Valid', ipAddress: '1.1.1.1' });
|
||||
const invalid = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Invalid',
|
||||
ipAddress: '2.2.2.2',
|
||||
});
|
||||
await invalid.invalidate('test');
|
||||
|
||||
const sessions = await Session.getUserSessions(testUserId);
|
||||
assertEquals(sessions.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate', () => {
|
||||
it('should invalidate session with reason', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
await session.invalidate('User logged out');
|
||||
|
||||
assertEquals(session.isValid, false);
|
||||
assertExists(session.invalidatedAt);
|
||||
assertEquals(session.invalidatedReason, 'User logged out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateAllUserSessions', () => {
|
||||
it('should invalidate all user sessions', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
||||
|
||||
const count = await Session.invalidateAllUserSessions(testUserId, 'Security logout');
|
||||
assertEquals(count, 3);
|
||||
|
||||
const remaining = await Session.getUserSessions(testUserId);
|
||||
assertEquals(remaining.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchActivity', () => {
|
||||
it('should update lastActivityAt', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
const originalActivity = session.lastActivityAt;
|
||||
|
||||
// Wait a bit to ensure time difference
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
await session.touchActivity();
|
||||
|
||||
assertEquals(session.lastActivityAt > originalActivity, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
test/unit/models/user.test.ts
Normal file
228
test/unit/models/user.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* User model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb } from '../../helpers/index.ts';
|
||||
import { User } from '../../../ts/models/user.ts';
|
||||
|
||||
describe('User Model', () => {
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create a user with valid data', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
passwordHash,
|
||||
displayName: 'Test User',
|
||||
});
|
||||
|
||||
assertExists(user.id);
|
||||
assertEquals(user.email, 'test@example.com');
|
||||
assertEquals(user.username, 'testuser');
|
||||
assertEquals(user.displayName, 'Test User');
|
||||
assertEquals(user.status, 'pending_verification');
|
||||
assertEquals(user.emailVerified, false);
|
||||
assertEquals(user.isPlatformAdmin, false);
|
||||
});
|
||||
|
||||
it('should lowercase email and username', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'TEST@EXAMPLE.COM',
|
||||
username: 'TestUser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
assertEquals(user.email, 'test@example.com');
|
||||
assertEquals(user.username, 'testuser');
|
||||
});
|
||||
|
||||
it('should use username as displayName if not provided', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'test2@example.com',
|
||||
username: 'testuser2',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
assertEquals(user.displayName, 'testuser2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByEmail', () => {
|
||||
it('should find user by email (case-insensitive)', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
await User.createUser({
|
||||
email: 'findme@example.com',
|
||||
username: 'findme',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const found = await User.findByEmail('FINDME@example.com');
|
||||
assertExists(found);
|
||||
assertEquals(found.email, 'findme@example.com');
|
||||
});
|
||||
|
||||
it('should return null for non-existent email', async () => {
|
||||
const found = await User.findByEmail('nonexistent@example.com');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUsername', () => {
|
||||
it('should find user by username (case-insensitive)', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
await User.createUser({
|
||||
email: 'user@example.com',
|
||||
username: 'findbyname',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const found = await User.findByUsername('FINDBYNAME');
|
||||
assertExists(found);
|
||||
assertEquals(found.username, 'findbyname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find user by ID', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const created = await User.createUser({
|
||||
email: 'byid@example.com',
|
||||
username: 'byid',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const found = await User.findById(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('password hashing', () => {
|
||||
it('should hash password with salt', async () => {
|
||||
const hash = await User.hashPassword('mypassword');
|
||||
assertExists(hash);
|
||||
assertEquals(hash.includes(':'), true);
|
||||
|
||||
const [salt, _hashPart] = hash.split(':');
|
||||
assertEquals(salt.length, 32); // 16 bytes = 32 hex chars
|
||||
});
|
||||
|
||||
it('should produce different hashes for same password', async () => {
|
||||
const hash1 = await User.hashPassword('samepassword');
|
||||
const hash2 = await User.hashPassword('samepassword');
|
||||
|
||||
// Different salts should produce different hashes
|
||||
assertEquals(hash1 !== hash2, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyPassword', () => {
|
||||
it('should verify correct password', async () => {
|
||||
const passwordHash = await User.hashPassword('correctpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'verify@example.com',
|
||||
username: 'verifyuser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const isValid = await user.verifyPassword('correctpassword');
|
||||
assertEquals(isValid, true);
|
||||
});
|
||||
|
||||
it('should reject incorrect password', async () => {
|
||||
const passwordHash = await User.hashPassword('correctpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'reject@example.com',
|
||||
username: 'rejectuser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const isValid = await user.verifyPassword('wrongpassword');
|
||||
assertEquals(isValid, false);
|
||||
});
|
||||
|
||||
it('should reject empty password', async () => {
|
||||
const passwordHash = await User.hashPassword('correctpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'empty@example.com',
|
||||
username: 'emptyuser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const isValid = await user.verifyPassword('');
|
||||
assertEquals(isValid, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('should return true for active status', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'active@example.com',
|
||||
username: 'activeuser',
|
||||
passwordHash,
|
||||
});
|
||||
user.status = 'active';
|
||||
await user.save();
|
||||
|
||||
assertEquals(user.isActive, true);
|
||||
});
|
||||
|
||||
it('should return false for suspended status', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'suspended@example.com',
|
||||
username: 'suspendeduser',
|
||||
passwordHash,
|
||||
});
|
||||
user.status = 'suspended';
|
||||
|
||||
assertEquals(user.isActive, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlatformAdmin', () => {
|
||||
it('should default to false', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'notadmin@example.com',
|
||||
username: 'notadmin',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
assertEquals(user.isPlatformAdmin, false);
|
||||
assertEquals(user.isSystemAdmin, false);
|
||||
});
|
||||
|
||||
it('should be settable to true', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'admin@example.com',
|
||||
username: 'adminuser',
|
||||
passwordHash,
|
||||
});
|
||||
user.isPlatformAdmin = true;
|
||||
await user.save();
|
||||
|
||||
const found = await User.findById(user.id);
|
||||
assertEquals(found!.isPlatformAdmin, true);
|
||||
assertEquals(found!.isSystemAdmin, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
224
test/unit/services/auth.service.test.ts
Normal file
224
test/unit/services/auth.service.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* AuthService unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { AuthService } from '../../../ts/services/auth.service.ts';
|
||||
import { Session } from '../../../ts/models/session.ts';
|
||||
import { testConfig } from '../../test.config.ts';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
authService = new AuthService({
|
||||
jwtSecret: testConfig.jwt.secret,
|
||||
accessTokenExpiresIn: 60, // 1 minute for tests
|
||||
refreshTokenExpiresIn: 300, // 5 minutes for tests
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should successfully login with valid credentials', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'login@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const result = await authService.login(user.email, password, {
|
||||
userAgent: 'TestAgent/1.0',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
assertEquals(result.success, true);
|
||||
assertExists(result.user);
|
||||
assertEquals(result.user.id, user.id);
|
||||
assertExists(result.accessToken);
|
||||
assertExists(result.refreshToken);
|
||||
assertExists(result.sessionId);
|
||||
});
|
||||
|
||||
it('should fail with invalid email', async () => {
|
||||
const result = await authService.login('nonexistent@example.com', 'password');
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('should fail with invalid password', async () => {
|
||||
const { user } = await createTestUser({ email: 'wrongpass@example.com' });
|
||||
|
||||
const result = await authService.login(user.email, 'wrongpassword');
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('should fail for inactive user', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'inactive@example.com',
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
const result = await authService.login(user.email, password);
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'ACCOUNT_INACTIVE');
|
||||
});
|
||||
|
||||
it('should create a session on successful login', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'session@example.com' });
|
||||
|
||||
const result = await authService.login(user.email, password);
|
||||
|
||||
assertEquals(result.success, true);
|
||||
assertExists(result.sessionId);
|
||||
|
||||
const session = await Session.findValidSession(result.sessionId!);
|
||||
assertExists(session);
|
||||
assertEquals(session.userId, user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh access token with valid refresh token', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'refresh@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
assertEquals(loginResult.success, true);
|
||||
|
||||
const refreshResult = await authService.refresh(loginResult.refreshToken!);
|
||||
|
||||
assertEquals(refreshResult.success, true);
|
||||
assertExists(refreshResult.accessToken);
|
||||
assertEquals(refreshResult.sessionId, loginResult.sessionId);
|
||||
});
|
||||
|
||||
it('should fail with invalid refresh token', async () => {
|
||||
const result = await authService.refresh('invalid-token');
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail when session is invalidated', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'invalidsession@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
// Invalidate session
|
||||
const session = await Session.findValidSession(loginResult.sessionId!);
|
||||
await session!.invalidate('test');
|
||||
|
||||
const refreshResult = await authService.refresh(loginResult.refreshToken!);
|
||||
|
||||
assertEquals(refreshResult.success, false);
|
||||
assertEquals(refreshResult.errorCode, 'SESSION_INVALID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAccessToken', () => {
|
||||
it('should validate valid access token', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'validate@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
const validation = await authService.validateAccessToken(loginResult.accessToken!);
|
||||
|
||||
assertExists(validation);
|
||||
assertEquals(validation.user.id, user.id);
|
||||
assertEquals(validation.sessionId, loginResult.sessionId);
|
||||
});
|
||||
|
||||
it('should reject invalid access token', async () => {
|
||||
const validation = await authService.validateAccessToken('invalid-token');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject when session is invalidated', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'invalidated@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
// Invalidate session
|
||||
const session = await Session.findValidSession(loginResult.sessionId!);
|
||||
await session!.invalidate('test');
|
||||
|
||||
const validation = await authService.validateAccessToken(loginResult.accessToken!);
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should invalidate session', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'logout@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
const success = await authService.logout(loginResult.sessionId!);
|
||||
|
||||
assertEquals(success, true);
|
||||
|
||||
const session = await Session.findValidSession(loginResult.sessionId!);
|
||||
assertEquals(session, null);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const success = await authService.logout('non-existent-session-id');
|
||||
|
||||
assertEquals(success, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutAll', () => {
|
||||
it('should invalidate all user sessions', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'logoutall@example.com' });
|
||||
|
||||
// Create multiple sessions
|
||||
await authService.login(user.email, password);
|
||||
await authService.login(user.email, password);
|
||||
await authService.login(user.email, password);
|
||||
|
||||
const count = await authService.logoutAll(user.id);
|
||||
|
||||
assertEquals(count, 3);
|
||||
|
||||
const sessions = await Session.getUserSessions(user.id);
|
||||
assertEquals(sessions.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static password methods', () => {
|
||||
it('should hash and verify password', async () => {
|
||||
const password = 'MySecurePassword123!';
|
||||
const hash = await AuthService.hashPassword(password);
|
||||
|
||||
const isValid = await AuthService.verifyPassword(password, hash);
|
||||
assertEquals(isValid, true);
|
||||
|
||||
const isInvalid = await AuthService.verifyPassword('WrongPassword', hash);
|
||||
assertEquals(isInvalid, false);
|
||||
});
|
||||
|
||||
it('should generate different hashes for same password', async () => {
|
||||
const password = 'SamePassword';
|
||||
const hash1 = await AuthService.hashPassword(password);
|
||||
const hash2 = await AuthService.hashPassword(password);
|
||||
|
||||
assertEquals(hash1 !== hash2, true);
|
||||
|
||||
// But both should verify
|
||||
assertEquals(await AuthService.verifyPassword(password, hash1), true);
|
||||
assertEquals(await AuthService.verifyPassword(password, hash2), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
260
test/unit/services/token.service.test.ts
Normal file
260
test/unit/services/token.service.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* TokenService unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { TokenService } from '../../../ts/services/token.service.ts';
|
||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||
|
||||
describe('TokenService', () => {
|
||||
let tokenService: TokenService;
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
tokenService = new TokenService();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
describe('createToken', () => {
|
||||
it('should create token with correct format', async () => {
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'test-token',
|
||||
protocols: ['npm', 'oci'],
|
||||
scopes: [{ protocol: '*', actions: ['read', 'write'] }],
|
||||
});
|
||||
|
||||
assertExists(result.rawToken);
|
||||
assertExists(result.token);
|
||||
|
||||
// Check token format: srg_{prefix}_{random}
|
||||
assertMatch(result.rawToken, /^srg_[a-z0-9]+_[a-z0-9]+$/);
|
||||
assertEquals(result.token.name, 'test-token');
|
||||
assertEquals(result.token.protocols.includes('npm'), true);
|
||||
assertEquals(result.token.protocols.includes('oci'), true);
|
||||
});
|
||||
|
||||
it('should store hashed token', async () => {
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'hashed-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
// The stored token should be hashed
|
||||
assertEquals(result.token.tokenHash !== result.rawToken, true);
|
||||
assertEquals(result.token.tokenHash.length, 64); // SHA-256 hex
|
||||
});
|
||||
|
||||
it('should set expiration when provided', async () => {
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'expiring-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
expiresInDays: 30,
|
||||
});
|
||||
|
||||
assertExists(result.token.expiresAt);
|
||||
|
||||
const expectedExpiry = new Date();
|
||||
expectedExpiry.setDate(expectedExpiry.getDate() + 30);
|
||||
|
||||
// Should be within a few seconds of expected
|
||||
const diff = Math.abs(result.token.expiresAt.getTime() - expectedExpiry.getTime());
|
||||
assertEquals(diff < 5000, true);
|
||||
});
|
||||
|
||||
it('should create org-owned token', async () => {
|
||||
const orgId = 'test-org-123';
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
organizationId: orgId,
|
||||
name: 'org-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', organizationId: orgId, actions: ['read', 'write'] }],
|
||||
});
|
||||
|
||||
assertEquals(result.token.organizationId, orgId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('should validate correct token', async () => {
|
||||
const { rawToken } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'valid-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertExists(validation);
|
||||
assertEquals(validation.userId, testUserId);
|
||||
assertEquals(validation.protocols.includes('npm'), true);
|
||||
});
|
||||
|
||||
it('should reject invalid token format', async () => {
|
||||
const validation = await tokenService.validateToken('invalid-format', '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject non-existent token', async () => {
|
||||
const validation = await tokenService.validateToken('srg_abc123_def456', '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject revoked token', async () => {
|
||||
const { rawToken, token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'revoked-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await token.revoke('Test revocation');
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject expired token', async () => {
|
||||
const { rawToken, token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'expired-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
expiresInDays: 1,
|
||||
});
|
||||
|
||||
// Manually set expiry to past
|
||||
token.expiresAt = new Date(Date.now() - 86400000);
|
||||
await token.save();
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should record usage on validation', async () => {
|
||||
const { rawToken, token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'usage-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await tokenService.validateToken(rawToken, '192.168.1.100');
|
||||
|
||||
// Reload token from DB
|
||||
const updated = await ApiToken.findByHash(token.tokenHash);
|
||||
assertExists(updated);
|
||||
assertExists(updated.lastUsedAt);
|
||||
assertEquals(updated.lastUsedIp, '192.168.1.100');
|
||||
assertEquals(updated.usageCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserTokens', () => {
|
||||
it('should return all user tokens', async () => {
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'token1',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'token2',
|
||||
protocols: ['oci'],
|
||||
scopes: [{ protocol: 'oci', actions: ['read'] }],
|
||||
});
|
||||
|
||||
const tokens = await tokenService.getUserTokens(testUserId);
|
||||
|
||||
assertEquals(tokens.length, 2);
|
||||
});
|
||||
|
||||
it('should not return revoked tokens', async () => {
|
||||
const { token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'revoked',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'active',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await token.revoke('test');
|
||||
|
||||
const tokens = await tokenService.getUserTokens(testUserId);
|
||||
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].name, 'active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeToken', () => {
|
||||
it('should revoke token with reason', async () => {
|
||||
const { token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'to-revoke',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await tokenService.revokeToken(token.id, 'Security concern');
|
||||
|
||||
const updated = await ApiToken.findByPrefix(token.tokenPrefix);
|
||||
assertExists(updated);
|
||||
assertEquals(updated.isRevoked, true);
|
||||
assertEquals(updated.revokedReason, 'Security concern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgTokens', () => {
|
||||
it('should return organization tokens', async () => {
|
||||
const orgId = 'org-123';
|
||||
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
organizationId: orgId,
|
||||
name: 'org-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'personal-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
const tokens = await tokenService.getOrgTokens(orgId);
|
||||
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].organizationId, orgId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/registry',
|
||||
version: '1.2.0',
|
||||
version: '1.3.0',
|
||||
description: 'Enterprise-grade multi-protocol package registry'
|
||||
}
|
||||
|
||||
461
ts/api/handlers/admin.auth.api.ts
Normal file
461
ts/api/handlers/admin.auth.api.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Admin Auth API handlers
|
||||
* Platform admin endpoints for managing authentication providers and settings
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
|
||||
import { cryptoService } from '../../services/crypto.service.ts';
|
||||
import { externalAuthService } from '../../services/external.auth.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import type {
|
||||
ICreateAuthProviderDto,
|
||||
IUpdateAuthProviderDto,
|
||||
} from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class AdminAuthApi {
|
||||
/**
|
||||
* Check if actor is platform admin
|
||||
*/
|
||||
private requirePlatformAdmin(ctx: IApiContext): IApiResponse | null {
|
||||
if (!ctx.actor?.userId || !ctx.actor.user?.isPlatformAdmin) {
|
||||
return {
|
||||
status: 403,
|
||||
body: { error: 'Platform admin access required' },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/auth/providers
|
||||
* List all authentication providers
|
||||
*/
|
||||
public async listProviders(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const providers = await AuthProvider.getAllProviders();
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
providers: providers.map((p) => p.toAdminInfo()),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] List providers error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to list providers' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/auth/providers
|
||||
* Create a new authentication provider
|
||||
*/
|
||||
public async createProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = (await ctx.request.json()) as ICreateAuthProviderDto;
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.displayName || !body.type) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'name, displayName, and type are required' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
const existing = await AuthProvider.findByName(body.name);
|
||||
if (existing) {
|
||||
return {
|
||||
status: 409,
|
||||
body: { error: 'Provider name already exists' },
|
||||
};
|
||||
}
|
||||
|
||||
// Validate type-specific config
|
||||
if (body.type === 'oidc' && !body.oauthConfig) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'oauthConfig is required for OIDC provider' },
|
||||
};
|
||||
}
|
||||
if (body.type === 'ldap' && !body.ldapConfig) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'ldapConfig is required for LDAP provider' },
|
||||
};
|
||||
}
|
||||
|
||||
let provider: AuthProvider;
|
||||
|
||||
if (body.type === 'oidc' && body.oauthConfig) {
|
||||
// Encrypt client secret
|
||||
const encryptedSecret = await cryptoService.encrypt(body.oauthConfig.clientSecretEncrypted);
|
||||
|
||||
provider = await AuthProvider.createOAuthProvider({
|
||||
name: body.name,
|
||||
displayName: body.displayName,
|
||||
oauthConfig: {
|
||||
...body.oauthConfig,
|
||||
clientSecretEncrypted: encryptedSecret,
|
||||
},
|
||||
attributeMapping: body.attributeMapping,
|
||||
provisioning: body.provisioning,
|
||||
createdById: ctx.actor!.userId,
|
||||
});
|
||||
} else if (body.type === 'ldap' && body.ldapConfig) {
|
||||
// Encrypt bind password
|
||||
const encryptedPassword = await cryptoService.encrypt(body.ldapConfig.bindPasswordEncrypted);
|
||||
|
||||
provider = await AuthProvider.createLdapProvider({
|
||||
name: body.name,
|
||||
displayName: body.displayName,
|
||||
ldapConfig: {
|
||||
...body.ldapConfig,
|
||||
bindPasswordEncrypted: encryptedPassword,
|
||||
},
|
||||
attributeMapping: body.attributeMapping,
|
||||
provisioning: body.provisioning,
|
||||
createdById: ctx.actor!.userId,
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Invalid provider type' },
|
||||
};
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_CREATED', 'system', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_created',
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: provider.toAdminInfo(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Create provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to create provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/auth/providers/:id
|
||||
* Get a specific authentication provider
|
||||
*/
|
||||
public async getProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const provider = await AuthProvider.findById(id);
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: 'Provider not found' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: provider.toAdminInfo(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Get provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to get provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/auth/providers/:id
|
||||
* Update an authentication provider
|
||||
*/
|
||||
public async updateProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const provider = await AuthProvider.findById(id);
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: 'Provider not found' },
|
||||
};
|
||||
}
|
||||
|
||||
const body = (await ctx.request.json()) as IUpdateAuthProviderDto;
|
||||
|
||||
// Update basic fields
|
||||
if (body.displayName !== undefined) provider.displayName = body.displayName;
|
||||
if (body.status !== undefined) provider.status = body.status;
|
||||
if (body.priority !== undefined) provider.priority = body.priority;
|
||||
|
||||
// Update OAuth config
|
||||
if (body.oauthConfig && provider.oauthConfig) {
|
||||
const newOAuthConfig = { ...provider.oauthConfig, ...body.oauthConfig };
|
||||
|
||||
// Encrypt new client secret if provided and not already encrypted
|
||||
if (
|
||||
body.oauthConfig.clientSecretEncrypted &&
|
||||
!cryptoService.isEncrypted(body.oauthConfig.clientSecretEncrypted)
|
||||
) {
|
||||
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
|
||||
body.oauthConfig.clientSecretEncrypted
|
||||
);
|
||||
}
|
||||
|
||||
provider.oauthConfig = newOAuthConfig;
|
||||
}
|
||||
|
||||
// Update LDAP config
|
||||
if (body.ldapConfig && provider.ldapConfig) {
|
||||
const newLdapConfig = { ...provider.ldapConfig, ...body.ldapConfig };
|
||||
|
||||
// Encrypt new bind password if provided and not already encrypted
|
||||
if (
|
||||
body.ldapConfig.bindPasswordEncrypted &&
|
||||
!cryptoService.isEncrypted(body.ldapConfig.bindPasswordEncrypted)
|
||||
) {
|
||||
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
|
||||
body.ldapConfig.bindPasswordEncrypted
|
||||
);
|
||||
}
|
||||
|
||||
provider.ldapConfig = newLdapConfig;
|
||||
}
|
||||
|
||||
// Update attribute mapping
|
||||
if (body.attributeMapping) {
|
||||
provider.attributeMapping = { ...provider.attributeMapping, ...body.attributeMapping };
|
||||
}
|
||||
|
||||
// Update provisioning settings
|
||||
if (body.provisioning) {
|
||||
provider.provisioning = { ...provider.provisioning, ...body.provisioning };
|
||||
}
|
||||
|
||||
await provider.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_updated',
|
||||
providerName: provider.name,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: provider.toAdminInfo(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Update provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to update provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/auth/providers/:id
|
||||
* Delete (or disable) an authentication provider
|
||||
*/
|
||||
public async deleteProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const provider = await AuthProvider.findById(id);
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: 'Provider not found' },
|
||||
};
|
||||
}
|
||||
|
||||
// For now, just disable the provider instead of deleting
|
||||
// This preserves audit history and linked identities
|
||||
provider.status = 'disabled';
|
||||
await provider.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_DELETED', 'system', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_disabled',
|
||||
providerName: provider.name,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Provider disabled' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Delete provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to delete provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/auth/providers/:id/test
|
||||
* Test provider connection
|
||||
*/
|
||||
public async testProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const result = await externalAuthService.testConnection(id);
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
resourceId: id,
|
||||
success: result.success,
|
||||
metadata: {
|
||||
action: 'auth_provider_tested',
|
||||
result: result.success ? 'success' : 'failure',
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Test provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to test provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/auth/settings
|
||||
* Get platform settings
|
||||
*/
|
||||
public async getSettings(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const settings = await PlatformSettings.get();
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: settings.id,
|
||||
auth: settings.auth,
|
||||
updatedAt: settings.updatedAt,
|
||||
updatedById: settings.updatedById,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Get settings error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to get settings' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/auth/settings
|
||||
* Update platform settings
|
||||
*/
|
||||
public async updateSettings(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const settings = await PlatformSettings.get();
|
||||
|
||||
if (body.auth) {
|
||||
await settings.updateAuthSettings(body.auth, ctx.actor!.userId);
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
resourceId: 'platform-settings',
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'platform_settings_updated',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: settings.id,
|
||||
auth: settings.auth,
|
||||
updatedAt: settings.updatedAt,
|
||||
updatedById: settings.updatedById,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Update settings error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to update settings' },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
188
ts/api/handlers/oauth.api.ts
Normal file
188
ts/api/handlers/oauth.api.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* OAuth API handlers
|
||||
* Public endpoints for OAuth/OIDC and LDAP authentication flows
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
|
||||
import { externalAuthService } from '../../services/external.auth.service.ts';
|
||||
|
||||
export class OAuthApi {
|
||||
/**
|
||||
* GET /api/v1/auth/providers
|
||||
* List active authentication providers (public info only)
|
||||
*/
|
||||
public async listProviders(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const settings = await PlatformSettings.get();
|
||||
const providers = await AuthProvider.getActiveProviders();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
providers: providers.map((p) => p.toPublicInfo()),
|
||||
localAuthEnabled: settings.auth.localAuthEnabled,
|
||||
defaultProviderId: settings.auth.defaultProviderId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OAuthApi] List providers error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to list providers' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/oauth/:id/authorize
|
||||
* Initiate OAuth flow - redirects to provider
|
||||
*/
|
||||
public async authorize(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const returnUrl = ctx.url.searchParams.get('returnUrl') || undefined;
|
||||
|
||||
const { authUrl } = await externalAuthService.initiateOAuth(id, returnUrl);
|
||||
|
||||
// Return redirect response
|
||||
return {
|
||||
status: 302,
|
||||
headers: { Location: authUrl },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OAuthApi] Authorize error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Authorization failed';
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/login?error=${encodeURIComponent(errorMessage)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/oauth/:id/callback
|
||||
* Handle OAuth callback from provider
|
||||
*/
|
||||
public async callback(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const code = ctx.url.searchParams.get('code');
|
||||
const state = ctx.url.searchParams.get('state');
|
||||
const error = ctx.url.searchParams.get('error');
|
||||
const errorDescription = ctx.url.searchParams.get('error_description');
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/login?error=${encodeURIComponent(errorDescription || error)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: '/login?error=missing_parameters',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await externalAuthService.handleOAuthCallback(
|
||||
{ code, state },
|
||||
{ ipAddress: ctx.ip, userAgent: ctx.userAgent }
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/login?error=${encodeURIComponent(result.errorCode || 'auth_failed')}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Redirect to OAuth callback page with tokens
|
||||
const params = new URLSearchParams({
|
||||
accessToken: result.accessToken!,
|
||||
refreshToken: result.refreshToken!,
|
||||
sessionId: result.sessionId!,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/oauth-callback?${params.toString()}`,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OAuthApi] Callback error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Callback failed';
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/login?error=${encodeURIComponent(errorMessage)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/ldap/:id/login
|
||||
* LDAP authentication with username/password
|
||||
*/
|
||||
public async ldapLogin(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const body = await ctx.request.json();
|
||||
const { username, password } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Username and password are required' },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await externalAuthService.authenticateLdap(id, username, password, {
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: result.errorMessage,
|
||||
code: result.errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
user: {
|
||||
id: result.user!.id,
|
||||
email: result.user!.email,
|
||||
username: result.user!.username,
|
||||
displayName: result.user!.displayName,
|
||||
isSystemAdmin: result.user!.isSystemAdmin,
|
||||
},
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionId: result.sessionId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OAuthApi] LDAP login error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'LDAP login failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@ export class OrganizationApi {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve organization by ID or name
|
||||
*/
|
||||
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
|
||||
return idOrName.startsWith('Organization:')
|
||||
? await Organization.findById(idOrName)
|
||||
: await Organization.findByName(idOrName);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations
|
||||
*/
|
||||
@@ -56,19 +65,20 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id
|
||||
* Supports lookup by ID (e.g., Organization:abc123) or by name (e.g., push.rocks)
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check access - public orgs are visible to all authenticated users
|
||||
if (!org.isPublic && ctx.actor?.userId) {
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
@@ -112,11 +122,11 @@ export class OrganizationApi {
|
||||
return { status: 400, body: { error: 'Organization name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
// Validate name format (allows dots for domain-like names)
|
||||
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens and dots' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,6 +186,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -184,18 +195,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission using org.id
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
|
||||
|
||||
@@ -232,6 +243,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -240,18 +252,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Owner access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
|
||||
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Owner access required' } };
|
||||
}
|
||||
|
||||
// TODO: Check for packages, repositories before deletion
|
||||
// For now, just delete the organization and memberships
|
||||
await org.delete();
|
||||
@@ -268,6 +280,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id/members
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -276,14 +289,19 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await OrganizationMember.getOrgMembers(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||
|
||||
// Fetch user details
|
||||
const membersWithUsers = await Promise.all(
|
||||
@@ -316,6 +334,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations/:id/members
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -324,13 +343,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { userId, role } = body as { userId: string; role: TOrganizationRole };
|
||||
|
||||
@@ -349,7 +373,7 @@ export class OrganizationApi {
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existing = await OrganizationMember.findMembership(id, userId);
|
||||
const existing = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'User is already a member' } };
|
||||
}
|
||||
@@ -357,7 +381,7 @@ export class OrganizationApi {
|
||||
// Add member
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = id;
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.addedById = ctx.actor.userId;
|
||||
@@ -366,11 +390,8 @@ export class OrganizationApi {
|
||||
await membership.save();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
}
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
@@ -388,6 +409,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id/members/:userId
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -396,13 +418,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { role } = body as { role: TOrganizationRole };
|
||||
|
||||
@@ -410,14 +437,14 @@ export class OrganizationApi {
|
||||
return { status: 400, body: { error: 'Valid role is required' } };
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
const membership = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot change last owner
|
||||
if (membership.role === 'owner' && role !== 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const owners = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
@@ -442,6 +469,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id/members/:userId
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -450,23 +478,28 @@ export class OrganizationApi {
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot remove last owner
|
||||
if (membership.role === 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const owners = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
@@ -476,11 +509,8 @@ export class OrganizationApi {
|
||||
await membership.delete();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
}
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
|
||||
@@ -16,6 +16,8 @@ import { RepositoryApi } from './handlers/repository.api.ts';
|
||||
import { PackageApi } from './handlers/package.api.ts';
|
||||
import { TokenApi } from './handlers/token.api.ts';
|
||||
import { AuditApi } from './handlers/audit.api.ts';
|
||||
import { AdminAuthApi } from './handlers/admin.auth.api.ts';
|
||||
import { OAuthApi } from './handlers/oauth.api.ts';
|
||||
|
||||
export interface IApiContext {
|
||||
request: Request;
|
||||
@@ -57,6 +59,8 @@ export class ApiRouter {
|
||||
private packageApi: PackageApi;
|
||||
private tokenApi: TokenApi;
|
||||
private auditApi: AuditApi;
|
||||
private adminAuthApi: AdminAuthApi;
|
||||
private oauthApi: OAuthApi;
|
||||
|
||||
constructor() {
|
||||
this.authService = new AuthService();
|
||||
@@ -71,6 +75,8 @@ export class ApiRouter {
|
||||
this.packageApi = new PackageApi(this.permissionService);
|
||||
this.tokenApi = new TokenApi(this.tokenService);
|
||||
this.auditApi = new AuditApi(this.permissionService);
|
||||
this.adminAuthApi = new AdminAuthApi();
|
||||
this.oauthApi = new OAuthApi();
|
||||
|
||||
this.registerRoutes();
|
||||
}
|
||||
@@ -124,6 +130,22 @@ export class ApiRouter {
|
||||
|
||||
// Audit routes
|
||||
this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx));
|
||||
|
||||
// OAuth/External auth routes (public)
|
||||
this.addRoute('GET', '/api/v1/auth/providers', (ctx) => this.oauthApi.listProviders(ctx));
|
||||
this.addRoute('GET', '/api/v1/auth/oauth/:id/authorize', (ctx) => this.oauthApi.authorize(ctx));
|
||||
this.addRoute('GET', '/api/v1/auth/oauth/:id/callback', (ctx) => this.oauthApi.callback(ctx));
|
||||
this.addRoute('POST', '/api/v1/auth/ldap/:id/login', (ctx) => this.oauthApi.ldapLogin(ctx));
|
||||
|
||||
// Admin auth routes (platform admin only)
|
||||
this.addRoute('GET', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.listProviders(ctx));
|
||||
this.addRoute('POST', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.createProvider(ctx));
|
||||
this.addRoute('GET', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.getProvider(ctx));
|
||||
this.addRoute('PUT', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.updateProvider(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.deleteProvider(ctx));
|
||||
this.addRoute('POST', '/api/v1/admin/auth/providers/:id/test', (ctx) => this.adminAuthApi.testProvider(ctx));
|
||||
this.addRoute('GET', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.getSettings(ctx));
|
||||
this.addRoute('PUT', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.updateSettings(ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface IOrganization {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
avatarUrl?: string;
|
||||
website?: string;
|
||||
isPublic: boolean;
|
||||
memberCount: number;
|
||||
plan: TOrganizationPlan;
|
||||
settings: IOrganizationSettings;
|
||||
billingEmail?: string;
|
||||
@@ -283,3 +286,140 @@ export interface ICreateTokenDto {
|
||||
scopes: ITokenScope[];
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// External Authentication Types
|
||||
// =============================================================================
|
||||
|
||||
export type TAuthProviderType = 'oidc' | 'ldap';
|
||||
export type TAuthProviderStatus = 'active' | 'disabled' | 'testing';
|
||||
|
||||
export interface IOAuthConfig {
|
||||
clientId: string;
|
||||
clientSecretEncrypted: string; // AES-256-GCM encrypted
|
||||
issuer: string; // OIDC issuer URL (used for discovery)
|
||||
authorizationUrl?: string; // Override discovery
|
||||
tokenUrl?: string; // Override discovery
|
||||
userInfoUrl?: string; // Override discovery
|
||||
scopes: string[];
|
||||
callbackUrl: string;
|
||||
}
|
||||
|
||||
export interface ILdapConfig {
|
||||
serverUrl: string; // ldap:// or ldaps://
|
||||
bindDn: string;
|
||||
bindPasswordEncrypted: string; // AES-256-GCM encrypted
|
||||
baseDn: string;
|
||||
userSearchFilter: string; // e.g., "(uid={{username}})" or "(sAMAccountName={{username}})"
|
||||
tlsEnabled: boolean;
|
||||
tlsCaCert?: string;
|
||||
}
|
||||
|
||||
export interface IAttributeMapping {
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
groups?: string;
|
||||
}
|
||||
|
||||
export interface IProvisioningSettings {
|
||||
jitEnabled: boolean; // Create user on first login
|
||||
autoLinkByEmail: boolean; // Link to existing user by email match
|
||||
allowedEmailDomains?: string[]; // Restrict to specific domains
|
||||
}
|
||||
|
||||
export interface IAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
status: TAuthProviderStatus;
|
||||
priority: number;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping: IAttributeMapping;
|
||||
provisioning: IProvisioningSettings;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdById: string;
|
||||
lastTestedAt?: Date;
|
||||
lastTestResult?: 'success' | 'failure';
|
||||
lastTestError?: string;
|
||||
}
|
||||
|
||||
export interface IExternalIdentity {
|
||||
id: string;
|
||||
userId: string;
|
||||
providerId: string;
|
||||
externalId: string;
|
||||
externalEmail?: string;
|
||||
externalUsername?: string;
|
||||
rawAttributes?: Record<string, unknown>;
|
||||
lastLoginAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface IPlatformAuthSettings {
|
||||
localAuthEnabled: boolean;
|
||||
allowUserRegistration: boolean;
|
||||
sessionDurationMinutes: number;
|
||||
defaultProviderId?: string;
|
||||
}
|
||||
|
||||
export interface IPlatformSettings {
|
||||
id: string;
|
||||
auth: IPlatformAuthSettings;
|
||||
updatedAt: Date;
|
||||
updatedById?: string;
|
||||
}
|
||||
|
||||
// External auth flow types
|
||||
export interface IExternalUserInfo {
|
||||
externalId: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
groups?: string[];
|
||||
rawAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IConnectionTestResult {
|
||||
success: boolean;
|
||||
latencyMs: number;
|
||||
serverInfo?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IExternalAuthResult {
|
||||
success: boolean;
|
||||
user?: IUser;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
sessionId?: string;
|
||||
isNewUser?: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// Admin DTOs
|
||||
export interface ICreateAuthProviderDto {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping?: IAttributeMapping;
|
||||
provisioning?: IProvisioningSettings;
|
||||
}
|
||||
|
||||
export interface IUpdateAuthProviderDto {
|
||||
displayName?: string;
|
||||
status?: TAuthProviderStatus;
|
||||
priority?: number;
|
||||
oauthConfig?: Partial<IOAuthConfig>;
|
||||
ldapConfig?: Partial<ILdapConfig>;
|
||||
attributeMapping?: Partial<IAttributeMapping>;
|
||||
provisioning?: Partial<IProvisioningSettings>;
|
||||
}
|
||||
|
||||
252
ts/models/auth.provider.ts
Normal file
252
ts/models/auth.provider.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Authentication Provider model for Stack.Gallery Registry
|
||||
* Stores OAuth/OIDC and LDAP provider configurations
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type {
|
||||
IAuthProvider,
|
||||
TAuthProviderType,
|
||||
TAuthProviderStatus,
|
||||
IOAuthConfig,
|
||||
ILdapConfig,
|
||||
IAttributeMapping,
|
||||
IProvisioningSettings,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
const DEFAULT_ATTRIBUTE_MAPPING: IAttributeMapping = {
|
||||
email: 'email',
|
||||
username: 'preferred_username',
|
||||
displayName: 'name',
|
||||
};
|
||||
|
||||
const DEFAULT_PROVISIONING: IProvisioningSettings = {
|
||||
jitEnabled: true,
|
||||
autoLinkByEmail: true,
|
||||
};
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class AuthProvider
|
||||
extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
|
||||
implements IAuthProvider
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
public name: string = ''; // URL-safe slug identifier
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
public displayName: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public type: TAuthProviderType = 'oidc';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public status: TAuthProviderStatus = 'disabled';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public priority: number = 100; // Lower = shown first in UI
|
||||
|
||||
// Type-specific config (only one should be populated based on type)
|
||||
@plugins.smartdata.svDb()
|
||||
public oauthConfig?: IOAuthConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public ldapConfig?: ILdapConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public attributeMapping: IAttributeMapping = DEFAULT_ATTRIBUTE_MAPPING;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public provisioning: IProvisioningSettings = DEFAULT_PROVISIONING;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdById: string = '';
|
||||
|
||||
// Connection test tracking
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestedAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestResult?: 'success' | 'failure';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestError?: string;
|
||||
|
||||
/**
|
||||
* Find provider by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<AuthProvider | null> {
|
||||
return await AuthProvider.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find provider by name (slug)
|
||||
*/
|
||||
public static async findByName(name: string): Promise<AuthProvider | null> {
|
||||
return await AuthProvider.getInstance({ name: name.toLowerCase() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active providers (for login page)
|
||||
*/
|
||||
public static async getActiveProviders(): Promise<AuthProvider[]> {
|
||||
const providers = await AuthProvider.getInstances({ status: 'active' });
|
||||
return providers.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all providers (for admin)
|
||||
*/
|
||||
public static async getAllProviders(): Promise<AuthProvider[]> {
|
||||
const providers = await AuthProvider.getInstances({});
|
||||
return providers.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new OAuth/OIDC provider
|
||||
*/
|
||||
public static async createOAuthProvider(data: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
oauthConfig: IOAuthConfig;
|
||||
attributeMapping?: IAttributeMapping;
|
||||
provisioning?: IProvisioningSettings;
|
||||
createdById: string;
|
||||
}): Promise<AuthProvider> {
|
||||
const provider = new AuthProvider();
|
||||
provider.id = await AuthProvider.getNewId();
|
||||
provider.name = data.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
provider.displayName = data.displayName;
|
||||
provider.type = 'oidc';
|
||||
provider.status = 'disabled';
|
||||
provider.oauthConfig = data.oauthConfig;
|
||||
provider.attributeMapping = data.attributeMapping || DEFAULT_ATTRIBUTE_MAPPING;
|
||||
provider.provisioning = data.provisioning || DEFAULT_PROVISIONING;
|
||||
provider.createdById = data.createdById;
|
||||
provider.createdAt = new Date();
|
||||
provider.updatedAt = new Date();
|
||||
await provider.save();
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new LDAP provider
|
||||
*/
|
||||
public static async createLdapProvider(data: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
ldapConfig: ILdapConfig;
|
||||
attributeMapping?: IAttributeMapping;
|
||||
provisioning?: IProvisioningSettings;
|
||||
createdById: string;
|
||||
}): Promise<AuthProvider> {
|
||||
const provider = new AuthProvider();
|
||||
provider.id = await AuthProvider.getNewId();
|
||||
provider.name = data.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
provider.displayName = data.displayName;
|
||||
provider.type = 'ldap';
|
||||
provider.status = 'disabled';
|
||||
provider.ldapConfig = data.ldapConfig;
|
||||
provider.attributeMapping = data.attributeMapping || {
|
||||
email: 'mail',
|
||||
username: 'uid',
|
||||
displayName: 'displayName',
|
||||
};
|
||||
provider.provisioning = data.provisioning || DEFAULT_PROVISIONING;
|
||||
provider.createdById = data.createdById;
|
||||
provider.createdAt = new Date();
|
||||
provider.updatedAt = new Date();
|
||||
await provider.save();
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection test result
|
||||
*/
|
||||
public async updateTestResult(success: boolean, error?: string): Promise<void> {
|
||||
this.lastTestedAt = new Date();
|
||||
this.lastTestResult = success ? 'success' : 'failure';
|
||||
this.lastTestError = error;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Update timestamps before save
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await AuthProvider.getNewId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public info (for login page - no secrets)
|
||||
*/
|
||||
public toPublicInfo(): {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
} {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
displayName: this.displayName,
|
||||
type: this.type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin info (secrets masked)
|
||||
*/
|
||||
public toAdminInfo(): Record<string, unknown> {
|
||||
const info: Record<string, unknown> = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
displayName: this.displayName,
|
||||
type: this.type,
|
||||
status: this.status,
|
||||
priority: this.priority,
|
||||
attributeMapping: this.attributeMapping,
|
||||
provisioning: this.provisioning,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
createdById: this.createdById,
|
||||
lastTestedAt: this.lastTestedAt,
|
||||
lastTestResult: this.lastTestResult,
|
||||
lastTestError: this.lastTestError,
|
||||
};
|
||||
|
||||
// Mask secrets in config
|
||||
if (this.oauthConfig) {
|
||||
info.oauthConfig = {
|
||||
...this.oauthConfig,
|
||||
clientSecretEncrypted: this.oauthConfig.clientSecretEncrypted ? '********' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.ldapConfig) {
|
||||
info.ldapConfig = {
|
||||
...this.ldapConfig,
|
||||
bindPasswordEncrypted: this.ldapConfig.bindPasswordEncrypted ? '********' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
142
ts/models/external.identity.ts
Normal file
142
ts/models/external.identity.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* External Identity model for Stack.Gallery Registry
|
||||
* Links users to external authentication provider accounts
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IExternalIdentity } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class ExternalIdentity
|
||||
extends plugins.smartdata.SmartDataDbDoc<ExternalIdentity, ExternalIdentity>
|
||||
implements IExternalIdentity
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public userId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public providerId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public externalId: string = ''; // ID from the external provider
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public externalEmail?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public externalUsername?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public rawAttributes?: Record<string, unknown>;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastLoginAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Find by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by provider and external ID (unique combination)
|
||||
*/
|
||||
public static async findByExternalId(
|
||||
providerId: string,
|
||||
externalId: string
|
||||
): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ providerId, externalId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all identities for a user
|
||||
*/
|
||||
public static async findByUserId(userId: string): Promise<ExternalIdentity[]> {
|
||||
return await ExternalIdentity.getInstances({ userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find identity by user and provider
|
||||
*/
|
||||
public static async findByUserAndProvider(
|
||||
userId: string,
|
||||
providerId: string
|
||||
): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ userId, providerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new external identity link
|
||||
*/
|
||||
public static async createIdentity(data: {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
externalId: string;
|
||||
externalEmail?: string;
|
||||
externalUsername?: string;
|
||||
rawAttributes?: Record<string, unknown>;
|
||||
}): Promise<ExternalIdentity> {
|
||||
// Check if this external ID is already linked
|
||||
const existing = await ExternalIdentity.findByExternalId(data.providerId, data.externalId);
|
||||
if (existing) {
|
||||
throw new Error('This external account is already linked to a user');
|
||||
}
|
||||
|
||||
const identity = new ExternalIdentity();
|
||||
identity.id = await ExternalIdentity.getNewId();
|
||||
identity.userId = data.userId;
|
||||
identity.providerId = data.providerId;
|
||||
identity.externalId = data.externalId;
|
||||
identity.externalEmail = data.externalEmail;
|
||||
identity.externalUsername = data.externalUsername;
|
||||
identity.rawAttributes = data.rawAttributes;
|
||||
identity.lastLoginAt = new Date();
|
||||
identity.createdAt = new Date();
|
||||
await identity.save();
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last login time
|
||||
*/
|
||||
public async updateLastLogin(): Promise<void> {
|
||||
this.lastLoginAt = new Date();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update attributes from provider
|
||||
*/
|
||||
public async updateAttributes(data: {
|
||||
externalEmail?: string;
|
||||
externalUsername?: string;
|
||||
rawAttributes?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
if (data.externalEmail !== undefined) this.externalEmail = data.externalEmail;
|
||||
if (data.externalUsername !== undefined) this.externalUsername = data.externalUsername;
|
||||
if (data.rawAttributes !== undefined) this.rawAttributes = data.rawAttributes;
|
||||
this.lastLoginAt = new Date();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Generate ID before save
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await ExternalIdentity.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,8 @@ export { Package } from './package.ts';
|
||||
export { ApiToken } from './apitoken.ts';
|
||||
export { Session } from './session.ts';
|
||||
export { AuditLog } from './auditlog.ts';
|
||||
|
||||
// External authentication models
|
||||
export { AuthProvider } from './auth.provider.ts';
|
||||
export { ExternalIdentity } from './external.identity.ts';
|
||||
export { PlatformSettings } from './platform.settings.ts';
|
||||
|
||||
@@ -37,6 +37,15 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
@plugins.smartdata.svDb()
|
||||
public avatarUrl?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public website?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public isPublic: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public memberCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public plan: TOrganizationPlan = 'free';
|
||||
@@ -79,11 +88,11 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
description?: string;
|
||||
createdById: string;
|
||||
}): Promise<Organization> {
|
||||
// Validate name (URL-safe)
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
// Validate name (URL-safe, allows dots for domain-like names)
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name)) {
|
||||
throw new Error(
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens'
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens and dots'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +109,13 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
return org;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find organization by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<Organization | null> {
|
||||
return await Organization.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find organization by name (slug)
|
||||
*/
|
||||
|
||||
90
ts/models/platform.settings.ts
Normal file
90
ts/models/platform.settings.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Platform Settings model for Stack.Gallery Registry
|
||||
* Singleton model storing global platform configuration
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IPlatformSettings, IPlatformAuthSettings } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
|
||||
localAuthEnabled: true,
|
||||
allowUserRegistration: true,
|
||||
sessionDurationMinutes: 10080, // 7 days
|
||||
};
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class PlatformSettings
|
||||
extends plugins.smartdata.SmartDataDbDoc<PlatformSettings, PlatformSettings>
|
||||
implements IPlatformSettings
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = 'singleton';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public auth: IPlatformAuthSettings = DEFAULT_AUTH_SETTINGS;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedById?: string;
|
||||
|
||||
/**
|
||||
* Get the singleton settings instance (creates if not exists)
|
||||
*/
|
||||
public static async get(): Promise<PlatformSettings> {
|
||||
let settings = await PlatformSettings.getInstance({ id: 'singleton' });
|
||||
if (!settings) {
|
||||
settings = new PlatformSettings();
|
||||
settings.id = 'singleton';
|
||||
settings.auth = DEFAULT_AUTH_SETTINGS;
|
||||
settings.updatedAt = new Date();
|
||||
await settings.save();
|
||||
console.log('[PlatformSettings] Created default settings');
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update auth settings
|
||||
*/
|
||||
public async updateAuthSettings(
|
||||
settings: Partial<IPlatformAuthSettings>,
|
||||
updatedById?: string
|
||||
): Promise<void> {
|
||||
this.auth = { ...this.auth, ...settings };
|
||||
this.updatedAt = new Date();
|
||||
this.updatedById = updatedById;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if local auth is enabled
|
||||
*/
|
||||
public isLocalAuthEnabled(): boolean {
|
||||
return this.auth.localAuthEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if registration is allowed
|
||||
*/
|
||||
public isRegistrationAllowed(): boolean {
|
||||
return this.auth.allowUserRegistration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default provider ID (for auto-redirect)
|
||||
*/
|
||||
public getDefaultProviderId(): string | undefined {
|
||||
return this.auth.defaultProviderId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Ensure singleton ID
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.id = 'singleton';
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,16 @@ export class User extends plugins.smartdata.SmartDataDbDoc<User, User> implement
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
// External authentication fields
|
||||
@plugins.smartdata.svDb()
|
||||
public externalIdentityIds: string[] = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public canUseLocalAuth: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public provisionedByProviderId?: string; // Provider that JIT-created this user
|
||||
|
||||
/**
|
||||
* Create a new user instance
|
||||
*/
|
||||
|
||||
47
ts/services/auth/strategies/auth.strategy.interface.ts
Normal file
47
ts/services/auth/strategies/auth.strategy.interface.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Authentication Strategy Interface
|
||||
* Base interface for OAuth/OIDC and LDAP authentication strategies
|
||||
*/
|
||||
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthCallbackData {
|
||||
code: string;
|
||||
state: string;
|
||||
error?: string;
|
||||
errorDescription?: string;
|
||||
}
|
||||
|
||||
export interface IAuthStrategy {
|
||||
/**
|
||||
* Get the authorization URL for OAuth/OIDC flow
|
||||
* @param state - CSRF state token
|
||||
* @param nonce - Optional nonce for OIDC
|
||||
* @returns Authorization URL to redirect user to
|
||||
*/
|
||||
getAuthorizationUrl?(state: string, nonce?: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Handle OAuth/OIDC callback
|
||||
* @param data - Callback data including code and state
|
||||
* @returns External user info from the provider
|
||||
*/
|
||||
handleCallback?(data: IOAuthCallbackData): Promise<IExternalUserInfo>;
|
||||
|
||||
/**
|
||||
* Authenticate with credentials (LDAP)
|
||||
* @param username - Username
|
||||
* @param password - Password
|
||||
* @returns External user info if authentication succeeds
|
||||
*/
|
||||
authenticateCredentials?(username: string, password: string): Promise<IExternalUserInfo>;
|
||||
|
||||
/**
|
||||
* Test connection to the provider
|
||||
* @returns Connection test result
|
||||
*/
|
||||
testConnection(): Promise<IConnectionTestResult>;
|
||||
}
|
||||
8
ts/services/auth/strategies/index.ts
Normal file
8
ts/services/auth/strategies/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Auth Strategy exports
|
||||
*/
|
||||
|
||||
export type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||||
export { OAuthStrategy } from './oauth.strategy.ts';
|
||||
export { LdapStrategy } from './ldap.strategy.ts';
|
||||
export { AuthStrategyFactory } from './strategy.factory.ts';
|
||||
242
ts/services/auth/strategies/ldap.strategy.ts
Normal file
242
ts/services/auth/strategies/ldap.strategy.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* LDAP Authentication Strategy
|
||||
* Handles LDAP/Active Directory authentication
|
||||
*
|
||||
* Note: This is a basic implementation. For production use with actual LDAP,
|
||||
* you may need to integrate with a Deno-compatible LDAP library.
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
||||
|
||||
// LDAP entry type (simplified)
|
||||
interface ILdapEntry {
|
||||
dn: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class LdapStrategy implements IAuthStrategy {
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Authenticate user with LDAP credentials
|
||||
*/
|
||||
public async authenticateCredentials(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<IExternalUserInfo> {
|
||||
const config = this.provider.ldapConfig;
|
||||
if (!config) {
|
||||
throw new Error('LDAP config not found');
|
||||
}
|
||||
|
||||
// Escape username to prevent LDAP injection
|
||||
const escapedUsername = this.escapeLdap(username);
|
||||
|
||||
// Build user search filter
|
||||
const userFilter = config.userSearchFilter.replace('{{username}}', escapedUsername);
|
||||
|
||||
// Decrypt bind password
|
||||
const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted);
|
||||
|
||||
// Perform LDAP authentication
|
||||
// This is a placeholder - actual implementation would use an LDAP library
|
||||
const userEntry = await this.ldapBind(
|
||||
config.serverUrl,
|
||||
config.bindDn,
|
||||
bindPassword,
|
||||
config.baseDn,
|
||||
userFilter,
|
||||
password
|
||||
);
|
||||
|
||||
// Map LDAP attributes to user info
|
||||
return this.mapAttributes(userEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test LDAP connection
|
||||
*/
|
||||
public async testConnection(): Promise<IConnectionTestResult> {
|
||||
const start = Date.now();
|
||||
const config = this.provider.ldapConfig;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: 'LDAP config not found',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt bind password
|
||||
const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted);
|
||||
|
||||
// Test connection by binding with service account
|
||||
await this.testLdapConnection(
|
||||
config.serverUrl,
|
||||
config.bindDn,
|
||||
bindPassword,
|
||||
config.baseDn
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
latencyMs: Date.now() - start,
|
||||
serverInfo: {
|
||||
serverUrl: config.serverUrl,
|
||||
baseDn: config.baseDn,
|
||||
tlsEnabled: config.tlsEnabled,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special LDAP characters to prevent injection
|
||||
*/
|
||||
private escapeLdap(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\5c')
|
||||
.replace(/\*/g, '\\2a')
|
||||
.replace(/\(/g, '\\28')
|
||||
.replace(/\)/g, '\\29')
|
||||
.replace(/\x00/g, '\\00');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform LDAP bind and search
|
||||
* This is a placeholder implementation - actual LDAP would require a library
|
||||
*/
|
||||
private async ldapBind(
|
||||
serverUrl: string,
|
||||
bindDn: string,
|
||||
bindPassword: string,
|
||||
baseDn: string,
|
||||
userFilter: string,
|
||||
userPassword: string
|
||||
): Promise<ILdapEntry> {
|
||||
// In a real implementation, this would:
|
||||
// 1. Connect to LDAP server
|
||||
// 2. Bind with service account (bindDn/bindPassword)
|
||||
// 3. Search for user with userFilter
|
||||
// 4. Re-bind with user's DN and password to verify
|
||||
// 5. Return user entry if successful
|
||||
|
||||
// For now, we throw an error indicating LDAP needs to be configured
|
||||
// This allows the structure to be in place while the actual LDAP library
|
||||
// integration can be done separately
|
||||
|
||||
console.log('[LdapStrategy] LDAP auth attempt:', {
|
||||
serverUrl,
|
||||
baseDn,
|
||||
userFilter,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
'LDAP authentication is not yet fully implemented. ' +
|
||||
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test LDAP connection
|
||||
*/
|
||||
private async testLdapConnection(
|
||||
serverUrl: string,
|
||||
bindDn: string,
|
||||
bindPassword: string,
|
||||
baseDn: string
|
||||
): Promise<void> {
|
||||
// Similar to ldapBind, this is a placeholder
|
||||
// Would connect and bind with service account to verify connectivity
|
||||
|
||||
console.log('[LdapStrategy] Testing LDAP connection:', {
|
||||
serverUrl,
|
||||
bindDn,
|
||||
baseDn,
|
||||
});
|
||||
|
||||
// For now, check if server URL is valid
|
||||
if (!serverUrl.startsWith('ldap://') && !serverUrl.startsWith('ldaps://')) {
|
||||
throw new Error('Invalid LDAP server URL. Must start with ldap:// or ldaps://');
|
||||
}
|
||||
|
||||
// In a real implementation, we would actually connect here
|
||||
// For now, we just validate the configuration
|
||||
if (!bindDn || !bindPassword || !baseDn) {
|
||||
throw new Error('Missing required LDAP configuration');
|
||||
}
|
||||
|
||||
// Return success for configuration validation
|
||||
// Actual connectivity test would happen with LDAP library
|
||||
console.log('[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Map LDAP attributes to standard user info
|
||||
*/
|
||||
private mapAttributes(entry: ILdapEntry): IExternalUserInfo {
|
||||
const mapping = this.provider.attributeMapping;
|
||||
|
||||
// Get external ID (typically uid or sAMAccountName)
|
||||
const externalId = String(entry[mapping.username] || entry.dn);
|
||||
|
||||
// Get email
|
||||
const email = entry[mapping.email];
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('Email not found in LDAP entry');
|
||||
}
|
||||
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: entry[mapping.username]
|
||||
? String(entry[mapping.username])
|
||||
: undefined,
|
||||
displayName: entry[mapping.displayName]
|
||||
? String(entry[mapping.displayName])
|
||||
: undefined,
|
||||
groups: mapping.groups
|
||||
? this.parseGroups(entry[mapping.groups])
|
||||
: undefined,
|
||||
rawAttributes: entry as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LDAP group membership
|
||||
*/
|
||||
private parseGroups(memberOf: unknown): string[] {
|
||||
if (!memberOf) return [];
|
||||
|
||||
if (Array.isArray(memberOf)) {
|
||||
return memberOf.map((dn) => this.extractCnFromDn(String(dn)));
|
||||
}
|
||||
|
||||
return [this.extractCnFromDn(String(memberOf))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CN (Common Name) from a DN (Distinguished Name)
|
||||
*/
|
||||
private extractCnFromDn(dn: string): string {
|
||||
const match = dn.match(/^CN=([^,]+)/i);
|
||||
return match ? match[1] : dn;
|
||||
}
|
||||
}
|
||||
263
ts/services/auth/strategies/oauth.strategy.ts
Normal file
263
ts/services/auth/strategies/oauth.strategy.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* OAuth/OIDC Authentication Strategy
|
||||
* Handles OAuth 2.0 and OpenID Connect flows
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||||
|
||||
interface ITokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
interface IOIDCDiscovery {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
jwks_uri?: string;
|
||||
scopes_supported?: string[];
|
||||
}
|
||||
|
||||
export class OAuthStrategy implements IAuthStrategy {
|
||||
private discoveryCache: IOIDCDiscovery | null = null;
|
||||
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the authorization URL for initiating OAuth flow
|
||||
*/
|
||||
public async getAuthorizationUrl(state: string, nonce?: string): Promise<string> {
|
||||
const config = this.provider.oauthConfig;
|
||||
if (!config) {
|
||||
throw new Error('OAuth config not found');
|
||||
}
|
||||
|
||||
// Get authorization URL from config or discovery
|
||||
let authorizationUrl = config.authorizationUrl;
|
||||
if (!authorizationUrl) {
|
||||
const discovery = await this.getDiscovery();
|
||||
authorizationUrl = discovery.authorization_endpoint;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.callbackUrl,
|
||||
response_type: 'code',
|
||||
scope: config.scopes.join(' '),
|
||||
state,
|
||||
});
|
||||
|
||||
// Add nonce for OIDC
|
||||
if (nonce) {
|
||||
params.set('nonce', nonce);
|
||||
}
|
||||
|
||||
return `${authorizationUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback - exchange code for tokens and get user info
|
||||
*/
|
||||
public async handleCallback(data: IOAuthCallbackData): Promise<IExternalUserInfo> {
|
||||
if (data.error) {
|
||||
throw new Error(`OAuth error: ${data.error} - ${data.errorDescription || ''}`);
|
||||
}
|
||||
|
||||
const config = this.provider.oauthConfig;
|
||||
if (!config) {
|
||||
throw new Error('OAuth config not found');
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await this.exchangeCodeForTokens(data.code);
|
||||
|
||||
// Get user info
|
||||
const userInfo = await this.fetchUserInfo(tokens.access_token);
|
||||
|
||||
// Map attributes according to provider config
|
||||
return this.mapAttributes(userInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection by fetching OIDC discovery document
|
||||
*/
|
||||
public async testConnection(): Promise<IConnectionTestResult> {
|
||||
const start = Date.now();
|
||||
const config = this.provider.oauthConfig;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: 'OAuth config not found',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const discovery = await this.getDiscovery();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
latencyMs: Date.now() - start,
|
||||
serverInfo: {
|
||||
issuer: discovery.issuer,
|
||||
scopes_supported: discovery.scopes_supported,
|
||||
has_userinfo: !!discovery.userinfo_endpoint,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
private async exchangeCodeForTokens(code: string): Promise<ITokenResponse> {
|
||||
const config = this.provider.oauthConfig!;
|
||||
|
||||
// Get token URL from config or discovery
|
||||
let tokenUrl = config.tokenUrl;
|
||||
if (!tokenUrl) {
|
||||
const discovery = await this.getDiscovery();
|
||||
tokenUrl = discovery.token_endpoint;
|
||||
}
|
||||
|
||||
// Decrypt client secret
|
||||
const clientSecret = await this.cryptoService.decrypt(config.clientSecretEncrypted);
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.callbackUrl,
|
||||
client_id: config.clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user info from the provider
|
||||
*/
|
||||
private async fetchUserInfo(accessToken: string): Promise<Record<string, unknown>> {
|
||||
const config = this.provider.oauthConfig!;
|
||||
|
||||
// Get userinfo URL from config or discovery
|
||||
let userInfoUrl = config.userInfoUrl;
|
||||
if (!userInfoUrl) {
|
||||
const discovery = await this.getDiscovery();
|
||||
userInfoUrl = discovery.userinfo_endpoint;
|
||||
}
|
||||
|
||||
if (!userInfoUrl) {
|
||||
throw new Error('UserInfo endpoint not found');
|
||||
}
|
||||
|
||||
const response = await fetch(userInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`UserInfo fetch failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OIDC discovery document
|
||||
*/
|
||||
private async getDiscovery(): Promise<IOIDCDiscovery> {
|
||||
if (this.discoveryCache) {
|
||||
return this.discoveryCache;
|
||||
}
|
||||
|
||||
const config = this.provider.oauthConfig!;
|
||||
const discoveryUrl = `${config.issuer}/.well-known/openid-configuration`;
|
||||
|
||||
const response = await fetch(discoveryUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OIDC discovery failed: ${response.status}`);
|
||||
}
|
||||
|
||||
this.discoveryCache = await response.json();
|
||||
return this.discoveryCache!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map provider attributes to standard user info
|
||||
*/
|
||||
private mapAttributes(rawInfo: Record<string, unknown>): IExternalUserInfo {
|
||||
const mapping = this.provider.attributeMapping;
|
||||
|
||||
// Get external ID (sub for OIDC, or id for OAuth2)
|
||||
const externalId = String(rawInfo.sub || rawInfo.id || '');
|
||||
if (!externalId) {
|
||||
throw new Error('External ID not found in user info');
|
||||
}
|
||||
|
||||
// Get email
|
||||
const email = rawInfo[mapping.email];
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('Email not found in user info');
|
||||
}
|
||||
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: rawInfo[mapping.username]
|
||||
? String(rawInfo[mapping.username])
|
||||
: undefined,
|
||||
displayName: rawInfo[mapping.displayName]
|
||||
? String(rawInfo[mapping.displayName])
|
||||
: undefined,
|
||||
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
|
||||
? String(rawInfo[mapping.avatarUrl])
|
||||
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
|
||||
groups: mapping.groups && rawInfo[mapping.groups]
|
||||
? (Array.isArray(rawInfo[mapping.groups])
|
||||
? (rawInfo[mapping.groups] as string[])
|
||||
: [String(rawInfo[mapping.groups])])
|
||||
: undefined,
|
||||
rawAttributes: rawInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
28
ts/services/auth/strategies/strategy.factory.ts
Normal file
28
ts/services/auth/strategies/strategy.factory.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Auth Strategy Factory
|
||||
* Creates the appropriate authentication strategy based on provider type
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
||||
import { OAuthStrategy } from './oauth.strategy.ts';
|
||||
import { LdapStrategy } from './ldap.strategy.ts';
|
||||
|
||||
export class AuthStrategyFactory {
|
||||
constructor(private cryptoService: CryptoService) {}
|
||||
|
||||
/**
|
||||
* Create the appropriate strategy for a provider
|
||||
*/
|
||||
public create(provider: AuthProvider): IAuthStrategy {
|
||||
switch (provider.type) {
|
||||
case 'oidc':
|
||||
return new OAuthStrategy(provider, this.cryptoService);
|
||||
case 'ldap':
|
||||
return new LdapStrategy(provider, this.cryptoService);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${provider.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
178
ts/services/crypto.service.ts
Normal file
178
ts/services/crypto.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Crypto Service for Stack.Gallery Registry
|
||||
* Handles AES-256-GCM encryption/decryption of secrets
|
||||
*/
|
||||
|
||||
export class CryptoService {
|
||||
private masterKey: CryptoKey | null = null;
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the crypto service with the master key
|
||||
* The key should be a 64-character hex string (32 bytes = 256 bits)
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
|
||||
if (!keyHex) {
|
||||
console.warn(
|
||||
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
|
||||
);
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
|
||||
} else {
|
||||
if (keyHex.length !== 64) {
|
||||
throw new Error('AUTH_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
|
||||
}
|
||||
this.masterKey = await this.importKey(keyHex);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string
|
||||
* Returns format: base64(iv):base64(ciphertext)
|
||||
*/
|
||||
public async encrypt(plaintext: string): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.masterKey) {
|
||||
throw new Error('CryptoService not initialized');
|
||||
}
|
||||
|
||||
// Generate random IV (12 bytes for AES-GCM)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Encode plaintext to bytes
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
|
||||
// Encrypt
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
this.masterKey,
|
||||
encoded
|
||||
);
|
||||
|
||||
// Format: iv:ciphertext (both base64)
|
||||
const ivBase64 = this.bytesToBase64(iv);
|
||||
const ciphertextBase64 = this.bytesToBase64(new Uint8Array(encrypted));
|
||||
|
||||
return `${ivBase64}:${ciphertextBase64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted string
|
||||
* Expects format: base64(iv):base64(ciphertext)
|
||||
*/
|
||||
public async decrypt(ciphertext: string): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.masterKey) {
|
||||
throw new Error('CryptoService not initialized');
|
||||
}
|
||||
|
||||
const parts = ciphertext.split(':');
|
||||
if (parts.length !== 2) {
|
||||
throw new Error('Invalid ciphertext format');
|
||||
}
|
||||
|
||||
const [ivBase64, encryptedBase64] = parts;
|
||||
|
||||
// Decode from base64
|
||||
const iv = this.base64ToBytes(ivBase64);
|
||||
const encrypted = this.base64ToBytes(encryptedBase64);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
this.masterKey,
|
||||
encrypted
|
||||
);
|
||||
|
||||
// Decode to string
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is already encrypted (contains the iv:ciphertext format)
|
||||
*/
|
||||
public isEncrypted(value: string): boolean {
|
||||
if (!value || typeof value !== 'string') return false;
|
||||
const parts = value.split(':');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
// Check if both parts look like base64
|
||||
try {
|
||||
this.base64ToBytes(parts[0]);
|
||||
this.base64ToBytes(parts[1]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a hex key as CryptoKey
|
||||
*/
|
||||
private async importKey(keyHex: string): Promise<CryptoKey> {
|
||||
const keyBytes = this.hexToBytes(keyHex);
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bytes to hex string
|
||||
*/
|
||||
private bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to bytes
|
||||
*/
|
||||
private hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bytes to base64
|
||||
*/
|
||||
private bytesToBase64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 to bytes
|
||||
*/
|
||||
private base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new encryption key (for setup)
|
||||
* Returns a 64-character hex string
|
||||
*/
|
||||
public static generateKey(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const cryptoService = new CryptoService();
|
||||
568
ts/services/external.auth.service.ts
Normal file
568
ts/services/external.auth.service.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* External Auth Service for Stack.Gallery Registry
|
||||
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
||||
*/
|
||||
|
||||
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
|
||||
import { AuthService, type IAuthResult } from './auth.service.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
import { cryptoService } from './crypto.service.ts';
|
||||
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
|
||||
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthState {
|
||||
providerId: string;
|
||||
returnUrl?: string;
|
||||
nonce: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export class ExternalAuthService {
|
||||
private strategyFactory: AuthStrategyFactory;
|
||||
private authService: AuthService;
|
||||
private auditService: AuditService;
|
||||
|
||||
constructor() {
|
||||
this.strategyFactory = new AuthStrategyFactory(cryptoService);
|
||||
this.authService = new AuthService();
|
||||
this.auditService = new AuditService({ actorType: 'system' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth flow - returns authorization URL and state
|
||||
*/
|
||||
public async initiateOAuth(
|
||||
providerId: string,
|
||||
returnUrl?: string
|
||||
): Promise<{ authUrl: string; state: string }> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
if (provider.status !== 'active') {
|
||||
throw new Error('Provider is not active');
|
||||
}
|
||||
|
||||
if (provider.type !== 'oidc') {
|
||||
throw new Error('Provider is not an OAuth/OIDC provider');
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.getAuthorizationUrl) {
|
||||
throw new Error('Provider does not support OAuth flow');
|
||||
}
|
||||
|
||||
// Generate state with encoded data
|
||||
const state = await this.generateState(providerId, returnUrl);
|
||||
const nonce = crypto.randomUUID();
|
||||
|
||||
const authUrl = await strategy.getAuthorizationUrl(state, nonce);
|
||||
|
||||
return { authUrl, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback - exchange code for user and create session
|
||||
*/
|
||||
public async handleOAuthCallback(
|
||||
data: IOAuthCallbackData,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
// Validate state
|
||||
const stateData = await this.validateState(data.state);
|
||||
if (!stateData) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_STATE',
|
||||
errorMessage: 'Invalid or expired state',
|
||||
};
|
||||
}
|
||||
|
||||
// Get provider
|
||||
const provider = await AuthProvider.findById(stateData.providerId);
|
||||
if (!provider || provider.status !== 'active') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'PROVIDER_INACTIVE',
|
||||
errorMessage: 'Provider not found or inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle OAuth callback
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.handleCallback) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Provider does not support OAuth callback',
|
||||
};
|
||||
}
|
||||
|
||||
let externalUser: IExternalUserInfo;
|
||||
try {
|
||||
externalUser = await strategy.handleCallback(data);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'PROVIDER_ERROR',
|
||||
errorMessage: error instanceof Error ? error.message : 'Authentication failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||
|
||||
// Create session
|
||||
const session = await Session.createSession({
|
||||
userId: user.id,
|
||||
userAgent: options.userAgent || '',
|
||||
ipAddress: options.ipAddress || '',
|
||||
});
|
||||
|
||||
// Generate tokens using the existing AuthService approach
|
||||
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,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
isNewUser: isNew,
|
||||
authMethod: 'oauth',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with LDAP credentials
|
||||
*/
|
||||
public async authenticateLdap(
|
||||
providerId: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Invalid LDAP provider',
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.authenticateCredentials) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Provider does not support credential authentication',
|
||||
};
|
||||
}
|
||||
|
||||
let externalUser: IExternalUserInfo;
|
||||
try {
|
||||
externalUser = await strategy.authenticateCredentials(username, password);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
username,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'AUTH_FAILED',
|
||||
errorMessage: 'Invalid credentials',
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||
|
||||
// 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,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
isNewUser: isNew,
|
||||
authMethod: 'ldap',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an external provider to an existing user
|
||||
*/
|
||||
public async linkProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
externalUser: IExternalUserInfo
|
||||
): Promise<ExternalIdentity> {
|
||||
// Check if this external ID is already linked to another user
|
||||
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||||
if (existing) {
|
||||
if (existing.userId === userId) {
|
||||
// Already linked to this user, just update
|
||||
await existing.updateAttributes({
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
throw new Error('This external account is already linked to another user');
|
||||
}
|
||||
|
||||
// Create new identity link
|
||||
const identity = await ExternalIdentity.createIdentity({
|
||||
userId,
|
||||
providerId,
|
||||
externalId: externalUser.externalId,
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
|
||||
// Update user's external identity IDs
|
||||
const user = await User.findById(userId);
|
||||
if (user) {
|
||||
user.externalIdentityIds = [...(user.externalIdentityIds || []), identity.id];
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await this.auditService.log('USER_UPDATED', 'user', {
|
||||
resourceId: userId,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'link_provider',
|
||||
providerId,
|
||||
externalId: externalUser.externalId,
|
||||
},
|
||||
});
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink an external provider from a user
|
||||
*/
|
||||
public async unlinkProvider(userId: string, providerId: string): Promise<boolean> {
|
||||
const identity = await ExternalIdentity.findByUserAndProvider(userId, providerId);
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure user still has another auth method
|
||||
const user = await User.findById(userId);
|
||||
if (!user) return false;
|
||||
|
||||
const otherIdentities = await ExternalIdentity.findByUserId(userId);
|
||||
const hasLocalAuth = user.canUseLocalAuth && user.passwordHash;
|
||||
|
||||
if (otherIdentities.length <= 1 && !hasLocalAuth) {
|
||||
throw new Error('Cannot unlink last authentication method');
|
||||
}
|
||||
|
||||
// Remove identity
|
||||
await identity.delete();
|
||||
|
||||
// Update user's external identity IDs
|
||||
user.externalIdentityIds = user.externalIdentityIds.filter((id) => id !== identity.id);
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await this.auditService.log('USER_UPDATED', 'user', {
|
||||
resourceId: userId,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'unlink_provider',
|
||||
providerId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test provider connection
|
||||
*/
|
||||
public async testConnection(providerId: string): Promise<IConnectionTestResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: 0,
|
||||
error: 'Provider not found',
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
const result = await strategy.testConnection();
|
||||
|
||||
// Update provider test status
|
||||
await provider.updateTestResult(result.success, result.error);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create user from external authentication
|
||||
*/
|
||||
private async findOrCreateUser(
|
||||
provider: AuthProvider,
|
||||
externalUser: IExternalUserInfo,
|
||||
options: { ipAddress?: string } = {}
|
||||
): Promise<{ user: User; isNew: boolean }> {
|
||||
// 1. Check if external identity already exists
|
||||
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||||
provider.id,
|
||||
externalUser.externalId
|
||||
);
|
||||
|
||||
if (existingIdentity) {
|
||||
const user = await User.findById(existingIdentity.userId);
|
||||
if (user) {
|
||||
// Update identity with latest info
|
||||
await existingIdentity.updateAttributes({
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
return { user, isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try to link by email if enabled
|
||||
if (provider.provisioning.autoLinkByEmail && externalUser.email) {
|
||||
const existingUser = await User.findByEmail(externalUser.email);
|
||||
if (existingUser) {
|
||||
await this.linkProvider(existingUser.id, provider.id, externalUser);
|
||||
return { user: existingUser, isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create new user if JIT is enabled
|
||||
if (!provider.provisioning.jitEnabled) {
|
||||
throw new Error('User not found and JIT provisioning is disabled');
|
||||
}
|
||||
|
||||
// Check domain restrictions
|
||||
if (provider.provisioning.allowedEmailDomains?.length) {
|
||||
const domain = externalUser.email.split('@')[1];
|
||||
if (!provider.provisioning.allowedEmailDomains.includes(domain)) {
|
||||
throw new Error(`Email domain ${domain} is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique username
|
||||
let username = externalUser.username || externalUser.email.split('@')[0];
|
||||
username = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Ensure username is unique
|
||||
let counter = 0;
|
||||
let finalUsername = username;
|
||||
while (await User.findByUsername(finalUsername)) {
|
||||
counter++;
|
||||
finalUsername = `${username}${counter}`;
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = new User();
|
||||
user.id = await User.getNewId();
|
||||
user.email = externalUser.email.toLowerCase();
|
||||
user.username = finalUsername;
|
||||
user.displayName = externalUser.displayName || finalUsername;
|
||||
user.avatarUrl = externalUser.avatarUrl;
|
||||
user.status = 'active';
|
||||
user.emailVerified = true; // Trust the provider
|
||||
user.canUseLocalAuth = false; // No password set
|
||||
user.provisionedByProviderId = provider.id;
|
||||
user.passwordHash = ''; // No local password
|
||||
user.createdAt = new Date();
|
||||
user.updatedAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Link external identity
|
||||
await this.linkProvider(user.id, provider.id, externalUser);
|
||||
|
||||
return { user, isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth state token
|
||||
*/
|
||||
private async generateState(providerId: string, returnUrl?: string): Promise<string> {
|
||||
const stateData: IOAuthState = {
|
||||
providerId,
|
||||
returnUrl,
|
||||
nonce: crypto.randomUUID(),
|
||||
exp: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||
};
|
||||
|
||||
// Encode as base64
|
||||
return btoa(JSON.stringify(stateData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OAuth state token
|
||||
*/
|
||||
private async validateState(state: string): Promise<IOAuthState | null> {
|
||||
try {
|
||||
const stateData: IOAuthState = JSON.parse(atob(state));
|
||||
|
||||
// Check expiration
|
||||
if (stateData.exp < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return stateData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token (mirrors AuthService logic)
|
||||
*/
|
||||
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = 15 * 60; // 15 minutes
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'access',
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
return await this.signJwt(payload, jwtSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token (mirrors AuthService logic)
|
||||
*/
|
||||
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
|
||||
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'refresh',
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
return await this.signJwt(payload, jwtSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign JWT token
|
||||
*/
|
||||
private async signJwt(payload: Record<string, unknown>, secret: string): 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 encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const encodedSignature = this.base64UrlEncode(
|
||||
String.fromCharCode(...new Uint8Array(signature))
|
||||
);
|
||||
|
||||
return `${data}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode
|
||||
*/
|
||||
private base64UrlEncode(str: string): string {
|
||||
const base64 = btoa(str);
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const externalAuthService = new ExternalAuthService();
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { adminGuard } from './core/guards/admin.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -7,6 +8,13 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/login/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'oauth-callback',
|
||||
loadComponent: () =>
|
||||
import('./features/oauth-callback/oauth-callback.component').then(
|
||||
(m) => m.OAuthCallbackComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
@@ -38,14 +46,14 @@ export const routes: Routes = [
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':orgId',
|
||||
path: ':orgName',
|
||||
loadComponent: () =>
|
||||
import('./features/organizations/organization-detail.component').then(
|
||||
(m) => m.OrganizationDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':orgId/repositories/:repoId',
|
||||
path: ':orgName/repositories/:repoId',
|
||||
loadComponent: () =>
|
||||
import('./features/repositories/repository-detail.component').then(
|
||||
(m) => m.RepositoryDetailComponent
|
||||
@@ -86,6 +94,39 @@ export const routes: Routes = [
|
||||
(m) => m.SettingsComponent
|
||||
),
|
||||
},
|
||||
// Admin routes
|
||||
{
|
||||
path: 'admin',
|
||||
canActivate: [adminGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'auth',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/auth-providers/auth-providers.component').then(
|
||||
(m) => m.AuthProvidersComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/providers/new',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/auth-providers/provider-form.component').then(
|
||||
(m) => m.ProviderFormComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/providers/:id',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/auth-providers/provider-form.component').then(
|
||||
(m) => m.ProviderFormComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
27
ui/src/app/core/guards/admin.guard.ts
Normal file
27
ui/src/app/core/guards/admin.guard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, type CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const adminGuard: CanActivateFn = async () => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
// First check if authenticated
|
||||
if (!authService.isAuthenticated()) {
|
||||
// Try to refresh the token
|
||||
const refreshed = await authService.refreshAccessToken();
|
||||
if (!refreshed) {
|
||||
router.navigate(['/login']);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Then check if admin
|
||||
if (!authService.isAdmin()) {
|
||||
// Not an admin, redirect to dashboard
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
141
ui/src/app/core/services/admin-auth.service.ts
Normal file
141
ui/src/app/core/services/admin-auth.service.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// Types
|
||||
export type TAuthProviderType = 'oidc' | 'ldap';
|
||||
export type TAuthProviderStatus = 'active' | 'disabled' | 'testing';
|
||||
|
||||
export interface IOAuthConfig {
|
||||
clientId: string;
|
||||
clientSecretEncrypted: string;
|
||||
issuer: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
userInfoUrl?: string;
|
||||
scopes: string[];
|
||||
callbackUrl: string;
|
||||
}
|
||||
|
||||
export interface ILdapConfig {
|
||||
serverUrl: string;
|
||||
bindDn: string;
|
||||
bindPasswordEncrypted: string;
|
||||
baseDn: string;
|
||||
userSearchFilter: string;
|
||||
tlsEnabled: boolean;
|
||||
tlsCaCert?: string;
|
||||
}
|
||||
|
||||
export interface IAttributeMapping {
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
groups?: string;
|
||||
}
|
||||
|
||||
export interface IProvisioningSettings {
|
||||
jitEnabled: boolean;
|
||||
autoLinkByEmail: boolean;
|
||||
allowedEmailDomains?: string[];
|
||||
}
|
||||
|
||||
export interface IAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
status: TAuthProviderStatus;
|
||||
priority: number;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping: IAttributeMapping;
|
||||
provisioning: IProvisioningSettings;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdById: string;
|
||||
lastTestedAt?: string;
|
||||
lastTestResult?: 'success' | 'failure';
|
||||
lastTestError?: string;
|
||||
}
|
||||
|
||||
export interface IPlatformAuthSettings {
|
||||
localAuthEnabled: boolean;
|
||||
allowUserRegistration: boolean;
|
||||
sessionDurationMinutes: number;
|
||||
defaultProviderId?: string;
|
||||
}
|
||||
|
||||
export interface IPlatformSettings {
|
||||
id: string;
|
||||
auth: IPlatformAuthSettings;
|
||||
updatedAt: string;
|
||||
updatedById?: string;
|
||||
}
|
||||
|
||||
export interface IConnectionTestResult {
|
||||
success: boolean;
|
||||
latencyMs: number;
|
||||
serverInfo?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ICreateAuthProviderDto {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping?: IAttributeMapping;
|
||||
provisioning?: IProvisioningSettings;
|
||||
}
|
||||
|
||||
export interface IUpdateAuthProviderDto {
|
||||
displayName?: string;
|
||||
status?: TAuthProviderStatus;
|
||||
priority?: number;
|
||||
oauthConfig?: Partial<IOAuthConfig>;
|
||||
ldapConfig?: Partial<ILdapConfig>;
|
||||
attributeMapping?: Partial<IAttributeMapping>;
|
||||
provisioning?: Partial<IProvisioningSettings>;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminAuthService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Provider CRUD
|
||||
listProviders(): Observable<{ providers: IAuthProvider[] }> {
|
||||
return this.http.get<{ providers: IAuthProvider[] }>('/api/v1/admin/auth/providers');
|
||||
}
|
||||
|
||||
getProvider(id: string): Observable<IAuthProvider> {
|
||||
return this.http.get<IAuthProvider>(`/api/v1/admin/auth/providers/${id}`);
|
||||
}
|
||||
|
||||
createProvider(dto: ICreateAuthProviderDto): Observable<IAuthProvider> {
|
||||
return this.http.post<IAuthProvider>('/api/v1/admin/auth/providers', dto);
|
||||
}
|
||||
|
||||
updateProvider(id: string, dto: IUpdateAuthProviderDto): Observable<IAuthProvider> {
|
||||
return this.http.put<IAuthProvider>(`/api/v1/admin/auth/providers/${id}`, dto);
|
||||
}
|
||||
|
||||
deleteProvider(id: string): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(`/api/v1/admin/auth/providers/${id}`);
|
||||
}
|
||||
|
||||
testProvider(id: string): Observable<IConnectionTestResult> {
|
||||
return this.http.post<IConnectionTestResult>(`/api/v1/admin/auth/providers/${id}/test`, {});
|
||||
}
|
||||
|
||||
// Platform settings
|
||||
getSettings(): Observable<IPlatformSettings> {
|
||||
return this.http.get<IPlatformSettings>('/api/v1/admin/auth/settings');
|
||||
}
|
||||
|
||||
updateSettings(settings: Partial<{ auth: Partial<IPlatformAuthSettings> }>): Observable<IPlatformSettings> {
|
||||
return this.http.put<IPlatformSettings>('/api/v1/admin/auth/settings', settings);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,19 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback tokens from external providers
|
||||
*/
|
||||
handleOAuthCallback(accessToken: string, refreshToken: string, sessionId: string): void {
|
||||
this._accessToken.set(accessToken);
|
||||
this._refreshToken.set(refreshToken);
|
||||
this._sessionId.set(sessionId);
|
||||
this.saveToStorage();
|
||||
|
||||
// Fetch user info asynchronously
|
||||
this.fetchCurrentUser();
|
||||
}
|
||||
|
||||
private loadFromStorage(): void {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import {
|
||||
AdminAuthService,
|
||||
type IAuthProvider,
|
||||
type IPlatformSettings,
|
||||
type TAuthProviderStatus,
|
||||
} from '../../../core/services/admin-auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-providers',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div class="section-header mb-2">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Admin</span>
|
||||
</div>
|
||||
<h1 class="font-mono text-2xl font-bold text-foreground">Authentication Providers</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground mt-1">Configure OAuth and LDAP authentication</p>
|
||||
</div>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Platform Settings Card -->
|
||||
<div class="card mb-6">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
@if (settings()) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-medium text-foreground">Local Authentication</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">Allow email/password login</p>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleLocalAuth()"
|
||||
[class]="settings()!.auth.localAuthEnabled ? 'badge-accent' : 'badge-secondary'"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ settings()!.auth.localAuthEnabled ? 'Enabled' : 'Disabled' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-medium text-foreground">User Registration</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">Allow new account creation</p>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleRegistration()"
|
||||
[class]="settings()!.auth.allowUserRegistration ? 'badge-accent' : 'badge-secondary'"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ settings()!.auth.allowUserRegistration ? 'Enabled' : 'Disabled' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-medium text-foreground">Session Duration</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">{{ formatDuration(settings()!.auth.sessionDurationMinutes) }}</p>
|
||||
</div>
|
||||
<button (click)="showSettingsModal.set(true)" class="btn-ghost btn-sm">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="animate-pulse flex space-x-4">
|
||||
<div class="flex-1 space-y-2 py-1">
|
||||
<div class="h-4 bg-muted"></div>
|
||||
<div class="h-4 bg-muted w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Providers List -->
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else if (providers().length === 0) {
|
||||
<div class="card card-content text-center py-12">
|
||||
<svg class="w-16 h-16 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="font-mono text-lg font-medium text-foreground mb-2">No providers configured</h3>
|
||||
<p class="font-mono text-sm text-muted-foreground mb-4">Add an OAuth or LDAP provider to enable single sign-on</p>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-4">
|
||||
@for (provider of providers(); track provider.id) {
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 flex items-center justify-center flex-shrink-0" [class]="getProviderIconClass(provider.type)">
|
||||
@if (provider.type === 'oidc') {
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-mono font-semibold text-foreground">{{ provider.displayName }}</h3>
|
||||
<span [class]="getStatusBadgeClass(provider.status)">{{ provider.status }}</span>
|
||||
@if (settings()?.auth?.defaultProviderId === provider.id) {
|
||||
<span class="badge-primary">Default</span>
|
||||
}
|
||||
</div>
|
||||
<p class="font-mono text-sm text-muted-foreground">{{ provider.name }} · {{ provider.type.toUpperCase() }}</p>
|
||||
@if (provider.type === 'oidc' && provider.oauthConfig) {
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.oauthConfig.issuer }}</p>
|
||||
}
|
||||
@if (provider.type === 'ldap' && provider.ldapConfig) {
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.ldapConfig.serverUrl }}</p>
|
||||
}
|
||||
@if (provider.lastTestedAt) {
|
||||
<div class="flex items-center gap-2 mt-2 font-mono text-xs">
|
||||
@if (provider.lastTestResult === 'success') {
|
||||
<span class="text-accent flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Connection OK
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-destructive flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Connection Failed
|
||||
</span>
|
||||
}
|
||||
<span class="text-muted-foreground">
|
||||
tested {{ formatDate(provider.lastTestedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
(click)="testProvider(provider)"
|
||||
[disabled]="testing() === provider.id"
|
||||
class="btn-ghost btn-sm"
|
||||
>
|
||||
@if (testing() === provider.id) {
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
} @else {
|
||||
Test
|
||||
}
|
||||
</button>
|
||||
<button (click)="editProvider(provider)" class="btn-ghost btn-sm">Edit</button>
|
||||
<button (click)="confirmDelete(provider)" class="btn-ghost btn-sm text-destructive hover:text-destructive">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Create Modal -->
|
||||
@if (showCreateModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Select Provider Type</span>
|
||||
</div>
|
||||
<button (click)="showCreateModal.set(false)" class="btn-ghost btn-sm p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content space-y-3">
|
||||
<button
|
||||
(click)="createProvider('oidc')"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground">OAuth / OIDC</h4>
|
||||
<p class="font-mono text-xs text-muted-foreground">Google, Azure AD, Okta, Auth0, etc.</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
(click)="createProvider('ldap')"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 bg-accent/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground">LDAP / Active Directory</h4>
|
||||
<p class="font-mono text-xs text-muted-foreground">Enterprise directory service</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (providerToDelete()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator bg-destructive"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Delete Provider</span>
|
||||
</div>
|
||||
<button (click)="providerToDelete.set(null)" class="btn-ghost btn-sm p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p class="font-mono text-sm text-foreground">
|
||||
Are you sure you want to delete <strong>{{ providerToDelete()!.displayName }}</strong>?
|
||||
</p>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-2">
|
||||
Users who signed in with this provider will no longer be able to authenticate through it.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
<button (click)="providerToDelete.set(null)" class="btn-secondary btn-md">Cancel</button>
|
||||
<button (click)="deleteProvider()" [disabled]="deleting()" class="btn-destructive btn-md">
|
||||
@if (deleting()) {
|
||||
Deleting...
|
||||
} @else {
|
||||
Delete
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Settings Modal -->
|
||||
@if (showSettingsModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
|
||||
</div>
|
||||
<button (click)="showSettingsModal.set(false)" class="btn-ghost btn-sm p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Session Duration (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
[value]="editingSettings.sessionDurationMinutes"
|
||||
(input)="editingSettings.sessionDurationMinutes = +($any($event.target).value)"
|
||||
class="input"
|
||||
min="60"
|
||||
max="43200"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">How long user sessions remain valid (60-43200 minutes)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Default Provider</label>
|
||||
<select
|
||||
[value]="editingSettings.defaultProviderId || ''"
|
||||
(change)="editingSettings.defaultProviderId = $any($event.target).value || undefined"
|
||||
class="input"
|
||||
>
|
||||
<option value="">None (show all options)</option>
|
||||
@for (provider of providers(); track provider.id) {
|
||||
@if (provider.status === 'active') {
|
||||
<option [value]="provider.id">{{ provider.displayName }}</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Automatically redirect to this provider on login</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
<button (click)="showSettingsModal.set(false)" class="btn-secondary btn-md">Cancel</button>
|
||||
<button (click)="saveSettings()" [disabled]="savingSettings()" class="btn-primary btn-md">
|
||||
@if (savingSettings()) {
|
||||
Saving...
|
||||
} @else {
|
||||
Save
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AuthProvidersComponent implements OnInit {
|
||||
private adminAuthService = inject(AdminAuthService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
providers = signal<IAuthProvider[]>([]);
|
||||
settings = signal<IPlatformSettings | null>(null);
|
||||
loading = signal(true);
|
||||
testing = signal<string | null>(null);
|
||||
deleting = signal(false);
|
||||
savingSettings = signal(false);
|
||||
|
||||
showCreateModal = signal(false);
|
||||
showSettingsModal = signal(false);
|
||||
providerToDelete = signal<IAuthProvider | null>(null);
|
||||
selectedProviderForEdit = signal<IAuthProvider | null>(null);
|
||||
|
||||
editingSettings = {
|
||||
sessionDurationMinutes: 10080,
|
||||
defaultProviderId: undefined as string | undefined,
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const [providersRes, settingsRes] = await Promise.all([
|
||||
this.adminAuthService.listProviders().toPromise(),
|
||||
this.adminAuthService.getSettings().toPromise(),
|
||||
]);
|
||||
this.providers.set(providersRes?.providers || []);
|
||||
if (settingsRes) {
|
||||
this.settings.set(settingsRes);
|
||||
this.editingSettings = {
|
||||
sessionDurationMinutes: settingsRes.auth.sessionDurationMinutes,
|
||||
defaultProviderId: settingsRes.auth.defaultProviderId,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load authentication settings');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
createProvider(type: 'oidc' | 'ldap'): void {
|
||||
this.showCreateModal.set(false);
|
||||
// Navigate to provider form
|
||||
window.location.href = `/admin/auth/providers/new?type=${type}`;
|
||||
}
|
||||
|
||||
editProvider(provider: IAuthProvider): void {
|
||||
window.location.href = `/admin/auth/providers/${provider.id}`;
|
||||
}
|
||||
|
||||
async testProvider(provider: IAuthProvider): Promise<void> {
|
||||
this.testing.set(provider.id);
|
||||
try {
|
||||
const result = await this.adminAuthService.testProvider(provider.id).toPromise();
|
||||
if (result?.success) {
|
||||
this.toastService.success(`Connection successful (${result.latencyMs}ms)`);
|
||||
} else {
|
||||
this.toastService.error(result?.error || 'Connection failed');
|
||||
}
|
||||
// Reload to get updated test results
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to test provider');
|
||||
} finally {
|
||||
this.testing.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete(provider: IAuthProvider): void {
|
||||
this.providerToDelete.set(provider);
|
||||
}
|
||||
|
||||
async deleteProvider(): Promise<void> {
|
||||
const provider = this.providerToDelete();
|
||||
if (!provider) return;
|
||||
|
||||
this.deleting.set(true);
|
||||
try {
|
||||
await this.adminAuthService.deleteProvider(provider.id).toPromise();
|
||||
this.toastService.success('Provider deleted');
|
||||
this.providerToDelete.set(null);
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to delete provider');
|
||||
} finally {
|
||||
this.deleting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleLocalAuth(): Promise<void> {
|
||||
const current = this.settings();
|
||||
if (!current) return;
|
||||
|
||||
try {
|
||||
await this.adminAuthService.updateSettings({
|
||||
auth: { localAuthEnabled: !current.auth.localAuthEnabled },
|
||||
}).toPromise();
|
||||
this.toastService.success('Settings updated');
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to update settings');
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRegistration(): Promise<void> {
|
||||
const current = this.settings();
|
||||
if (!current) return;
|
||||
|
||||
try {
|
||||
await this.adminAuthService.updateSettings({
|
||||
auth: { allowUserRegistration: !current.auth.allowUserRegistration },
|
||||
}).toPromise();
|
||||
this.toastService.success('Settings updated');
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to update settings');
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
this.savingSettings.set(true);
|
||||
try {
|
||||
await this.adminAuthService.updateSettings({
|
||||
auth: {
|
||||
sessionDurationMinutes: this.editingSettings.sessionDurationMinutes,
|
||||
defaultProviderId: this.editingSettings.defaultProviderId,
|
||||
},
|
||||
}).toPromise();
|
||||
this.toastService.success('Settings saved');
|
||||
this.showSettingsModal.set(false);
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to save settings');
|
||||
} finally {
|
||||
this.savingSettings.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
getProviderIconClass(type: string): string {
|
||||
return type === 'oidc' ? 'bg-primary/10 text-primary' : 'bg-accent/10 text-accent';
|
||||
}
|
||||
|
||||
getStatusBadgeClass(status: TAuthProviderStatus): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'badge-accent';
|
||||
case 'testing':
|
||||
return 'badge-warning';
|
||||
case 'disabled':
|
||||
return 'badge-secondary';
|
||||
default:
|
||||
return 'badge-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} minutes`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
|
||||
return `${Math.round(minutes / 1440)} days`;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
AdminAuthService,
|
||||
type IAuthProvider,
|
||||
type ICreateAuthProviderDto,
|
||||
type IUpdateAuthProviderDto,
|
||||
type TAuthProviderType,
|
||||
type TAuthProviderStatus,
|
||||
} from '../../../core/services/admin-auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-provider-form',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<div class="section-header mb-2">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Admin / Auth Providers</span>
|
||||
</div>
|
||||
<h1 class="font-mono text-2xl font-bold text-foreground">
|
||||
{{ isEditMode() ? 'Edit Provider' : 'New ' + (providerType() === 'oidc' ? 'OAuth/OIDC' : 'LDAP') + ' Provider' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else {
|
||||
<form (ngSubmit)="saveProvider()" class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Basic Information</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Name (identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.name"
|
||||
name="name"
|
||||
class="input"
|
||||
placeholder="google, azure-ad, corp-ldap"
|
||||
required
|
||||
[disabled]="isEditMode()"
|
||||
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase, alphanumeric with hyphens</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.displayName"
|
||||
name="displayName"
|
||||
class="input"
|
||||
placeholder="Google SSO, Corporate LDAP"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Shown on login page</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (isEditMode()) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Status</label>
|
||||
<select [(ngModel)]="form.status" name="status" class="input">
|
||||
<option value="active">Active</option>
|
||||
<option value="testing">Testing</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="form.priority"
|
||||
name="priority"
|
||||
class="input"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Higher = shown first (0-100)</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Config -->
|
||||
@if (providerType() === 'oidc') {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">OAuth / OIDC Configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.issuer"
|
||||
name="issuer"
|
||||
class="input"
|
||||
placeholder="https://accounts.google.com"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">OIDC discovery endpoint base URL</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.oauthConfig.clientId"
|
||||
name="clientId"
|
||||
class="input"
|
||||
placeholder="your-client-id"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="form.oauthConfig.clientSecretEncrypted"
|
||||
name="clientSecret"
|
||||
class="input"
|
||||
[placeholder]="isEditMode() ? '••••••••' : 'your-client-secret'"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
@if (isEditMode()) {
|
||||
Leave empty to keep existing secret
|
||||
} @else {
|
||||
Will be encrypted at rest
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Scopes</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="scopesInput"
|
||||
name="scopes"
|
||||
class="input"
|
||||
placeholder="openid profile email"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Space-separated OAuth scopes</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Callback URL</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
[value]="getCallbackUrl()"
|
||||
class="input flex-1"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" (click)="copyCallbackUrl()" class="btn-secondary btn-md">Copy</button>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Add this to your OAuth provider's allowed redirect URIs</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced OAuth Settings -->
|
||||
<details class="group">
|
||||
<summary class="font-mono text-sm font-medium text-foreground cursor-pointer hover:text-primary">
|
||||
Advanced Settings
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-4 border-l border-border">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Authorization URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.authorizationUrl"
|
||||
name="authorizationUrl"
|
||||
class="input"
|
||||
placeholder="Override OIDC discovery"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Token URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.tokenUrl"
|
||||
name="tokenUrl"
|
||||
class="input"
|
||||
placeholder="Override OIDC discovery"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">User Info URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.userInfoUrl"
|
||||
name="userInfoUrl"
|
||||
class="input"
|
||||
placeholder="Override OIDC discovery"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LDAP Config -->
|
||||
@if (providerType() === 'ldap') {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">LDAP Configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Server URL</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.serverUrl"
|
||||
name="serverUrl"
|
||||
class="input"
|
||||
placeholder="ldap://ldap.example.com:389"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">LDAP or LDAPS protocol URL</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Bind DN</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.bindDn"
|
||||
name="bindDn"
|
||||
class="input"
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Bind Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="form.ldapConfig.bindPasswordEncrypted"
|
||||
name="bindPassword"
|
||||
class="input"
|
||||
[placeholder]="isEditMode() ? '••••••••' : 'your-bind-password'"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
@if (isEditMode()) {
|
||||
Leave empty to keep existing password
|
||||
} @else {
|
||||
Will be encrypted at rest
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Base DN</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.baseDn"
|
||||
name="baseDn"
|
||||
class="input"
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Base DN for user searches</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">User Search Filter</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.userSearchFilter"
|
||||
name="userSearchFilter"
|
||||
class="input"
|
||||
[placeholder]="'(uid=' + '{{' + 'username' + '}}' + ')'"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Use double-brace username placeholder</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.ldapConfig.tlsEnabled"
|
||||
name="tlsEnabled"
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="font-mono text-sm text-foreground">Enable TLS/StartTLS</span>
|
||||
</label>
|
||||
</div>
|
||||
@if (form.ldapConfig.tlsEnabled) {
|
||||
<div>
|
||||
<label class="label block mb-1.5">CA Certificate (optional)</label>
|
||||
<textarea
|
||||
[(ngModel)]="form.ldapConfig.tlsCaCert"
|
||||
name="tlsCaCert"
|
||||
class="input min-h-[100px] font-mono text-xs"
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
></textarea>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">PEM-encoded CA certificate for self-signed servers</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Attribute Mapping -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Attribute Mapping</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<p class="font-mono text-xs text-muted-foreground">
|
||||
Map provider attributes to user fields. Use claim names for OAuth or attribute names for LDAP.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Email</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.email"
|
||||
name="mapEmail"
|
||||
class="input"
|
||||
placeholder="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.username"
|
||||
name="mapUsername"
|
||||
class="input"
|
||||
[placeholder]="providerType() === 'oidc' ? 'preferred_username' : 'uid'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.displayName"
|
||||
name="mapDisplayName"
|
||||
class="input"
|
||||
[placeholder]="providerType() === 'oidc' ? 'name' : 'cn'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Avatar URL (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.avatarUrl"
|
||||
name="mapAvatarUrl"
|
||||
class="input"
|
||||
placeholder="picture"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Groups (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.groups"
|
||||
name="mapGroups"
|
||||
class="input"
|
||||
[placeholder]="providerType() === 'oidc' ? 'groups' : 'memberOf'"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">For future group sync functionality</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provisioning Settings -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">User Provisioning</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.provisioning.jitEnabled"
|
||||
name="jitEnabled"
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="font-mono text-sm text-foreground">Just-in-Time Provisioning</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-muted-foreground pl-6">
|
||||
Automatically create user accounts on first login
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.provisioning.autoLinkByEmail"
|
||||
name="autoLinkByEmail"
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="font-mono text-sm text-foreground">Auto-Link by Email</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-muted-foreground pl-6">
|
||||
Automatically link to existing accounts with matching email addresses
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="label block mb-1.5">Allowed Email Domains (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="domainsInput"
|
||||
name="allowedDomains"
|
||||
class="input"
|
||||
placeholder="example.com, corp.example.com"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
Comma-separated. Leave empty to allow all domains.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center">
|
||||
<button type="button" (click)="cancel()" class="btn-secondary btn-md">Cancel</button>
|
||||
<div class="flex gap-3">
|
||||
@if (isEditMode()) {
|
||||
<button type="button" (click)="testConnection()" [disabled]="testing()" class="btn-secondary btn-md">
|
||||
@if (testing()) {
|
||||
Testing...
|
||||
} @else {
|
||||
Test Connection
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button type="submit" [disabled]="saving()" class="btn-primary btn-md">
|
||||
@if (saving()) {
|
||||
Saving...
|
||||
} @else {
|
||||
{{ isEditMode() ? 'Save Changes' : 'Create Provider' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ProviderFormComponent implements OnInit {
|
||||
private adminAuthService = inject(AdminAuthService);
|
||||
private toastService = inject(ToastService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
testing = signal(false);
|
||||
isEditMode = signal(false);
|
||||
providerType = signal<TAuthProviderType>('oidc');
|
||||
providerId = signal<string | null>(null);
|
||||
|
||||
scopesInput = 'openid profile email';
|
||||
domainsInput = '';
|
||||
|
||||
form = {
|
||||
name: '',
|
||||
displayName: '',
|
||||
status: 'testing' as TAuthProviderStatus,
|
||||
priority: 0,
|
||||
oauthConfig: {
|
||||
clientId: '',
|
||||
clientSecretEncrypted: '',
|
||||
issuer: '',
|
||||
authorizationUrl: '',
|
||||
tokenUrl: '',
|
||||
userInfoUrl: '',
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
callbackUrl: '',
|
||||
},
|
||||
ldapConfig: {
|
||||
serverUrl: '',
|
||||
bindDn: '',
|
||||
bindPasswordEncrypted: '',
|
||||
baseDn: '',
|
||||
userSearchFilter: '(uid={{username}})',
|
||||
tlsEnabled: false,
|
||||
tlsCaCert: '',
|
||||
},
|
||||
attributeMapping: {
|
||||
email: 'email',
|
||||
username: 'preferred_username',
|
||||
displayName: 'name',
|
||||
avatarUrl: '',
|
||||
groups: '',
|
||||
},
|
||||
provisioning: {
|
||||
jitEnabled: true,
|
||||
autoLinkByEmail: true,
|
||||
allowedEmailDomains: [] as string[],
|
||||
},
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
// Check for edit mode
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id && id !== 'new') {
|
||||
this.isEditMode.set(true);
|
||||
this.providerId.set(id);
|
||||
this.loadProvider(id);
|
||||
} else {
|
||||
// New provider mode
|
||||
const type = this.route.snapshot.queryParamMap.get('type') as TAuthProviderType;
|
||||
if (type && (type === 'oidc' || type === 'ldap')) {
|
||||
this.providerType.set(type);
|
||||
this.setDefaultMappings(type);
|
||||
}
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadProvider(id: string): Promise<void> {
|
||||
try {
|
||||
const provider = await this.adminAuthService.getProvider(id).toPromise();
|
||||
if (provider) {
|
||||
this.providerType.set(provider.type);
|
||||
this.form.name = provider.name;
|
||||
this.form.displayName = provider.displayName;
|
||||
this.form.status = provider.status;
|
||||
this.form.priority = provider.priority;
|
||||
|
||||
if (provider.oauthConfig) {
|
||||
this.form.oauthConfig = {
|
||||
...this.form.oauthConfig,
|
||||
...provider.oauthConfig,
|
||||
clientSecretEncrypted: '', // Don't show encrypted secret
|
||||
};
|
||||
this.scopesInput = provider.oauthConfig.scopes.join(' ');
|
||||
}
|
||||
|
||||
if (provider.ldapConfig) {
|
||||
this.form.ldapConfig = {
|
||||
...this.form.ldapConfig,
|
||||
...provider.ldapConfig,
|
||||
bindPasswordEncrypted: '', // Don't show encrypted password
|
||||
};
|
||||
}
|
||||
|
||||
if (provider.attributeMapping) {
|
||||
this.form.attributeMapping = { ...this.form.attributeMapping, ...provider.attributeMapping };
|
||||
}
|
||||
|
||||
if (provider.provisioning) {
|
||||
this.form.provisioning = { ...this.form.provisioning, ...provider.provisioning };
|
||||
this.domainsInput = provider.provisioning.allowedEmailDomains?.join(', ') || '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load provider');
|
||||
this.router.navigate(['/admin/auth']);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private setDefaultMappings(type: TAuthProviderType): void {
|
||||
if (type === 'ldap') {
|
||||
this.form.attributeMapping = {
|
||||
email: 'mail',
|
||||
username: 'uid',
|
||||
displayName: 'cn',
|
||||
avatarUrl: '',
|
||||
groups: 'memberOf',
|
||||
};
|
||||
this.form.ldapConfig.userSearchFilter = '(uid={{username}})';
|
||||
}
|
||||
}
|
||||
|
||||
getCallbackUrl(): string {
|
||||
const baseUrl = window.location.origin;
|
||||
const providerName = this.form.name || '{provider-name}';
|
||||
return `${baseUrl}/api/v1/auth/oauth/${providerName}/callback`;
|
||||
}
|
||||
|
||||
copyCallbackUrl(): void {
|
||||
navigator.clipboard.writeText(this.getCallbackUrl());
|
||||
this.toastService.success('Callback URL copied');
|
||||
}
|
||||
|
||||
async saveProvider(): Promise<void> {
|
||||
// Parse scopes and domains
|
||||
this.form.oauthConfig.scopes = this.scopesInput.split(/\s+/).filter(Boolean);
|
||||
this.form.provisioning.allowedEmailDomains = this.domainsInput
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
this.saving.set(true);
|
||||
|
||||
try {
|
||||
if (this.isEditMode()) {
|
||||
// Update existing provider
|
||||
const dto: IUpdateAuthProviderDto = {
|
||||
displayName: this.form.displayName,
|
||||
status: this.form.status,
|
||||
priority: this.form.priority,
|
||||
attributeMapping: this.form.attributeMapping,
|
||||
provisioning: this.form.provisioning,
|
||||
};
|
||||
|
||||
if (this.providerType() === 'oidc') {
|
||||
dto.oauthConfig = { ...this.form.oauthConfig };
|
||||
// Only include secret if changed
|
||||
if (!dto.oauthConfig.clientSecretEncrypted) {
|
||||
delete dto.oauthConfig.clientSecretEncrypted;
|
||||
}
|
||||
} else {
|
||||
dto.ldapConfig = { ...this.form.ldapConfig };
|
||||
// Only include password if changed
|
||||
if (!dto.ldapConfig.bindPasswordEncrypted) {
|
||||
delete dto.ldapConfig.bindPasswordEncrypted;
|
||||
}
|
||||
}
|
||||
|
||||
await this.adminAuthService.updateProvider(this.providerId()!, dto).toPromise();
|
||||
this.toastService.success('Provider updated');
|
||||
} else {
|
||||
// Create new provider
|
||||
const dto: ICreateAuthProviderDto = {
|
||||
name: this.form.name,
|
||||
displayName: this.form.displayName,
|
||||
type: this.providerType(),
|
||||
attributeMapping: this.form.attributeMapping,
|
||||
provisioning: this.form.provisioning,
|
||||
};
|
||||
|
||||
if (this.providerType() === 'oidc') {
|
||||
dto.oauthConfig = {
|
||||
...this.form.oauthConfig,
|
||||
callbackUrl: this.getCallbackUrl(),
|
||||
};
|
||||
} else {
|
||||
dto.ldapConfig = this.form.ldapConfig;
|
||||
}
|
||||
|
||||
await this.adminAuthService.createProvider(dto).toPromise();
|
||||
this.toastService.success('Provider created');
|
||||
}
|
||||
|
||||
this.router.navigate(['/admin/auth']);
|
||||
} catch (error: any) {
|
||||
const message = error?.error?.error || 'Failed to save provider';
|
||||
this.toastService.error(message);
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<void> {
|
||||
if (!this.providerId()) return;
|
||||
|
||||
this.testing.set(true);
|
||||
try {
|
||||
const result = await this.adminAuthService.testProvider(this.providerId()!).toPromise();
|
||||
if (result?.success) {
|
||||
this.toastService.success(`Connection successful (${result.latencyMs}ms)`);
|
||||
} else {
|
||||
this.toastService.error(result?.error || 'Connection failed');
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to test connection');
|
||||
} finally {
|
||||
this.testing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/admin/auth']);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
interface IPublicProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: 'oidc' | 'ldap';
|
||||
}
|
||||
|
||||
interface IProvidersResponse {
|
||||
providers: IPublicProvider[];
|
||||
localAuthEnabled: boolean;
|
||||
defaultProviderId?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -22,68 +37,202 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
<p class="font-mono text-sm text-muted-foreground mt-2 uppercase tracking-wider">Registry</p>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<form (ngSubmit)="login()" class="card p-6 space-y-6">
|
||||
<!-- Terminal header -->
|
||||
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||
<div class="terminal-dot dot-red"></div>
|
||||
<div class="terminal-dot dot-orange"></div>
|
||||
<div class="terminal-dot dot-green"></div>
|
||||
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">Sign In</span>
|
||||
@if (loadingProviders()) {
|
||||
<div class="card p-6 flex items-center justify-center">
|
||||
<svg class="animate-spin h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="label block mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
[(ngModel)]="email"
|
||||
name="email"
|
||||
class="input"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="label block mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
[(ngModel)]="password"
|
||||
name="password"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="p-3 bg-destructive/10 border border-destructive/30">
|
||||
<p class="font-mono text-sm text-destructive">{{ error() }}</p>
|
||||
} @else {
|
||||
<!-- SSO Providers -->
|
||||
@if (oauthProviders().length > 0) {
|
||||
<div class="space-y-3 mb-6">
|
||||
@for (provider of oauthProviders(); track provider.id) {
|
||||
<button
|
||||
(click)="loginWithOAuth(provider)"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-mono text-sm font-medium text-foreground">Continue with {{ provider.displayName }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
[disabled]="loading()"
|
||||
class="btn-primary btn-md w-full"
|
||||
>
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<!-- LDAP Providers -->
|
||||
@if (ldapProviders().length > 0 && !showLdapForm()) {
|
||||
<div class="space-y-3 mb-6">
|
||||
@for (provider of ldapProviders(); track provider.id) {
|
||||
<button
|
||||
(click)="selectLdapProvider(provider)"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 bg-accent/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-mono text-sm font-medium text-foreground">Sign in with {{ provider.displayName }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LDAP Login Form -->
|
||||
@if (showLdapForm() && selectedLdapProvider()) {
|
||||
<form (ngSubmit)="loginWithLdap()" class="card p-6 space-y-6 mb-6">
|
||||
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||
<div class="terminal-dot dot-red"></div>
|
||||
<div class="terminal-dot dot-orange"></div>
|
||||
<div class="terminal-dot dot-green"></div>
|
||||
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">{{ selectedLdapProvider()!.displayName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="ldapUsername" class="label block mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ldapUsername"
|
||||
[(ngModel)]="ldapUsername"
|
||||
name="ldapUsername"
|
||||
class="input"
|
||||
placeholder="your.username"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ldapPassword" class="label block mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="ldapPassword"
|
||||
[(ngModel)]="ldapPassword"
|
||||
name="ldapPassword"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (ldapError()) {
|
||||
<div class="p-3 bg-destructive/10 border border-destructive/30">
|
||||
<p class="font-mono text-sm text-destructive">{{ ldapError() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="button" (click)="cancelLdap()" class="btn-secondary btn-md flex-1">
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" [disabled]="ldapLoading()" class="btn-primary btn-md flex-1">
|
||||
@if (ldapLoading()) {
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- Divider -->
|
||||
@if ((oauthProviders().length > 0 || ldapProviders().length > 0) && localAuthEnabled() && !showLdapForm()) {
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="flex-1 border-t border-border"></div>
|
||||
<span class="font-mono text-xs text-muted-foreground uppercase">or</span>
|
||||
<div class="flex-1 border-t border-border"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Local login form -->
|
||||
@if (localAuthEnabled() && !showLdapForm()) {
|
||||
<form (ngSubmit)="login()" class="card p-6 space-y-6">
|
||||
<!-- Terminal header -->
|
||||
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||
<div class="terminal-dot dot-red"></div>
|
||||
<div class="terminal-dot dot-orange"></div>
|
||||
<div class="terminal-dot dot-green"></div>
|
||||
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">Sign In</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="label block mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
[(ngModel)]="email"
|
||||
name="email"
|
||||
class="input"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="label block mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
[(ngModel)]="password"
|
||||
name="password"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="p-3 bg-destructive/10 border border-destructive/30">
|
||||
<p class="font-mono text-sm text-destructive">{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
[disabled]="loading()"
|
||||
class="btn-primary btn-md w-full"
|
||||
>
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- No auth available message -->
|
||||
@if (!localAuthEnabled() && oauthProviders().length === 0 && ldapProviders().length === 0 && !showLdapForm()) {
|
||||
<div class="card p-6 text-center">
|
||||
<svg class="w-12 h-12 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
<p class="font-mono text-sm text-muted-foreground">
|
||||
No authentication methods available. Please contact your administrator.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<p class="text-center font-mono text-xs text-muted-foreground mt-6 uppercase tracking-wider">
|
||||
Enterprise Package Registry
|
||||
@@ -92,16 +241,73 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
export class LoginComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private toastService = inject(ToastService);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// Local login
|
||||
email = '';
|
||||
password = '';
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
// Providers
|
||||
loadingProviders = signal(true);
|
||||
localAuthEnabled = signal(true);
|
||||
oauthProviders = signal<IPublicProvider[]>([]);
|
||||
ldapProviders = signal<IPublicProvider[]>([]);
|
||||
|
||||
// LDAP form
|
||||
showLdapForm = signal(false);
|
||||
selectedLdapProvider = signal<IPublicProvider | null>(null);
|
||||
ldapUsername = '';
|
||||
ldapPassword = '';
|
||||
ldapLoading = signal(false);
|
||||
ldapError = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
// Check for error in URL params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const errorParam = params.get('error');
|
||||
if (errorParam) {
|
||||
this.error.set(decodeURIComponent(errorParam));
|
||||
}
|
||||
|
||||
this.loadProviders();
|
||||
}
|
||||
|
||||
private async loadProviders(): Promise<void> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<IProvidersResponse>('/api/v1/auth/providers')
|
||||
);
|
||||
|
||||
this.localAuthEnabled.set(response.localAuthEnabled);
|
||||
this.oauthProviders.set(response.providers.filter((p) => p.type === 'oidc'));
|
||||
this.ldapProviders.set(response.providers.filter((p) => p.type === 'ldap'));
|
||||
|
||||
// Auto-redirect to default provider if configured
|
||||
if (response.defaultProviderId && !this.error()) {
|
||||
const defaultProvider = response.providers.find((p) => p.id === response.defaultProviderId);
|
||||
if (defaultProvider) {
|
||||
if (defaultProvider.type === 'oidc') {
|
||||
this.loginWithOAuth(defaultProvider);
|
||||
return;
|
||||
} else if (defaultProvider.type === 'ldap') {
|
||||
this.selectLdapProvider(defaultProvider);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If providers endpoint fails, show local auth
|
||||
console.error('Failed to load providers:', error);
|
||||
} finally {
|
||||
this.loadingProviders.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
if (!this.email || !this.password) {
|
||||
this.error.set('Please enter your email and password');
|
||||
@@ -126,4 +332,62 @@ export class LoginComponent {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
loginWithOAuth(provider: IPublicProvider): void {
|
||||
// Redirect to OAuth authorization endpoint
|
||||
const returnUrl = encodeURIComponent(window.location.origin + '/dashboard');
|
||||
window.location.href = `/api/v1/auth/oauth/${provider.id}/authorize?returnUrl=${returnUrl}`;
|
||||
}
|
||||
|
||||
selectLdapProvider(provider: IPublicProvider): void {
|
||||
this.selectedLdapProvider.set(provider);
|
||||
this.showLdapForm.set(true);
|
||||
this.ldapUsername = '';
|
||||
this.ldapPassword = '';
|
||||
this.ldapError.set(null);
|
||||
}
|
||||
|
||||
cancelLdap(): void {
|
||||
this.showLdapForm.set(false);
|
||||
this.selectedLdapProvider.set(null);
|
||||
}
|
||||
|
||||
async loginWithLdap(): Promise<void> {
|
||||
const provider = this.selectedLdapProvider();
|
||||
if (!provider || !this.ldapUsername || !this.ldapPassword) {
|
||||
this.ldapError.set('Please enter your username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
this.ldapLoading.set(true);
|
||||
this.ldapError.set(null);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<{
|
||||
user: { id: string; email: string; username: string; displayName: string; isSystemAdmin: boolean };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
sessionId: string;
|
||||
}>(`/api/v1/auth/ldap/${provider.id}/login`, {
|
||||
username: this.ldapUsername,
|
||||
password: this.ldapPassword,
|
||||
})
|
||||
);
|
||||
|
||||
this.authService.handleOAuthCallback(
|
||||
response.accessToken,
|
||||
response.refreshToken,
|
||||
response.sessionId
|
||||
);
|
||||
|
||||
this.toastService.success('Welcome!');
|
||||
this.router.navigate(['/dashboard']);
|
||||
} catch (err: any) {
|
||||
const message = err?.error?.error || 'Authentication failed';
|
||||
this.ldapError.set(message);
|
||||
} finally {
|
||||
this.ldapLoading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-oauth-callback',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="max-w-md w-full text-center">
|
||||
@if (error()) {
|
||||
<div class="w-16 h-16 bg-destructive/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-10 h-10 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Authentication Failed</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground mb-6">{{ error() }}</p>
|
||||
<a href="/login" class="btn-primary btn-md">Back to Login</a>
|
||||
} @else {
|
||||
<div class="w-16 h-16 bg-primary flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="animate-spin w-10 h-10 text-primary-foreground" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Signing you in...</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground">Please wait while we complete authentication</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OAuthCallbackComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
error = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.handleCallback();
|
||||
}
|
||||
|
||||
private handleCallback(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('accessToken');
|
||||
const refreshToken = params.get('refreshToken');
|
||||
const sessionId = params.get('sessionId');
|
||||
const errorParam = params.get('error');
|
||||
|
||||
if (errorParam) {
|
||||
this.error.set(decodeURIComponent(errorParam));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken || !refreshToken || !sessionId) {
|
||||
this.error.set('Missing authentication tokens');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the tokens and redirect
|
||||
this.authService.handleOAuthCallback(accessToken, refreshToken, sessionId);
|
||||
this.toastService.success('Welcome!');
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
@@ -20,33 +20,36 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-xl flex items-center justify-center">
|
||||
<span class="text-2xl font-medium text-gray-600 dark:text-gray-300">
|
||||
<div class="w-16 h-16 bg-muted flex items-center justify-center">
|
||||
<span class="font-mono text-2xl font-medium text-muted-foreground">
|
||||
{{ organization()!.name.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.displayName }}</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">@{{ organization()!.name }}</p>
|
||||
<h1 class="font-mono text-2xl font-bold text-foreground">{{ organization()!.displayName }}</h1>
|
||||
<p class="font-mono text-muted-foreground">@{{ organization()!.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (organization()!.isPublic) {
|
||||
<span class="badge-default">Public</span>
|
||||
<span class="badge-accent">Public</span>
|
||||
} @else {
|
||||
<span class="badge-warning">Private</span>
|
||||
<span class="badge-primary">Private</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (organization()!.description) {
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ organization()!.description }}</p>
|
||||
<p class="font-mono text-muted-foreground mb-8">{{ organization()!.description }}</p>
|
||||
}
|
||||
|
||||
<!-- Repositories Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Repositories</h2>
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Repositories</span>
|
||||
</div>
|
||||
<button class="btn-primary btn-sm">
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
@@ -57,26 +60,26 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@if (repositories().length === 0) {
|
||||
<div class="card card-content text-center py-8">
|
||||
<p class="text-gray-500 dark:text-gray-400">No repositories yet</p>
|
||||
<p class="font-mono text-muted-foreground">No repositories yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@for (repo of repositories(); track repo.id) {
|
||||
<a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
|
||||
<a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary/50 transition-colors">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ repo.displayName }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ repo.name }}</p>
|
||||
<h3 class="font-mono font-medium text-foreground">{{ repo.displayName }}</h3>
|
||||
<p class="font-mono text-sm text-muted-foreground">{{ repo.name }}</p>
|
||||
</div>
|
||||
@if (repo.isPublic) {
|
||||
<span class="badge-default">Public</span>
|
||||
<span class="badge-accent">Public</span>
|
||||
}
|
||||
</div>
|
||||
@if (repo.description) {
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2 line-clamp-2">{{ repo.description }}</p>
|
||||
<p class="font-mono text-sm text-muted-foreground mt-2 line-clamp-2">{{ repo.description }}</p>
|
||||
}
|
||||
<div class="mt-3 flex items-center gap-4">
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center gap-1 font-mono text-sm text-muted-foreground">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
@@ -95,18 +98,24 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mb-4">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Statistics</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="card card-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Members</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.memberCount }}</p>
|
||||
<p class="font-mono text-sm text-muted-foreground">Members</p>
|
||||
<p class="font-mono text-2xl font-bold text-foreground">{{ organization()!.memberCount }}</p>
|
||||
</div>
|
||||
<div class="card card-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Repositories</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ repositories().length }}</p>
|
||||
<p class="font-mono text-sm text-muted-foreground">Repositories</p>
|
||||
<p class="font-mono text-2xl font-bold text-foreground">{{ repositories().length }}</p>
|
||||
</div>
|
||||
<div class="card card-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Created</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ formatDate(organization()!.createdAt) }}</p>
|
||||
<p class="font-mono text-sm text-muted-foreground">Created</p>
|
||||
<p class="font-mono text-2xl font-bold text-foreground">{{ formatDate(organization()!.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -123,18 +132,18 @@ export class OrganizationDetailComponent implements OnInit {
|
||||
loading = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
const orgId = this.route.snapshot.paramMap.get('orgId');
|
||||
if (orgId) {
|
||||
this.loadData(orgId);
|
||||
const orgName = this.route.snapshot.paramMap.get('orgName');
|
||||
if (orgName) {
|
||||
this.loadData(orgName);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData(orgId: string): Promise<void> {
|
||||
private async loadData(orgName: string): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const [org, reposResponse] = await Promise.all([
|
||||
this.apiService.getOrganization(orgId).toPromise(),
|
||||
this.apiService.getRepositories(orgId).toPromise(),
|
||||
this.apiService.getOrganization(orgName).toPromise(),
|
||||
this.apiService.getRepositories(orgName).toPromise(),
|
||||
]);
|
||||
this.organization.set(org || null);
|
||||
this.repositories.set(reposResponse?.repositories || []);
|
||||
|
||||
@@ -47,7 +47,7 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
} @else {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@for (org of organizations(); track org.id) {
|
||||
<a [routerLink]="['/organizations', org.id]" class="card hover:border-primary/50 transition-colors">
|
||||
<a [routerLink]="['/organizations', org.name]" class="card hover:border-primary/50 transition-colors">
|
||||
<div class="card-content">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 bg-muted flex items-center justify-center flex-shrink-0">
|
||||
@@ -84,8 +84,8 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
<!-- Create Modal -->
|
||||
@if (showCreateModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div class="card w-full max-w-md mx-4">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
@@ -105,20 +105,20 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
[(ngModel)]="newOrg.name"
|
||||
name="name"
|
||||
class="input"
|
||||
placeholder="my-organization"
|
||||
placeholder="push.rocks"
|
||||
required
|
||||
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
|
||||
pattern="^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase letters, numbers, and hyphens only</p>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase letters, numbers, hyphens, and dots (e.g., push.rocks)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<label class="label block mb-1.5">Display Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newOrg.displayName"
|
||||
name="displayName"
|
||||
class="input"
|
||||
placeholder="My Organization"
|
||||
placeholder="Defaults to name if empty"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -139,6 +139,11 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<label for="isPublic" class="font-mono text-sm text-foreground">Make this organization public</label>
|
||||
<button type="button" (click)="showPublicExplainer.set(true)" class="btn-ghost p-0 h-5 w-5 text-muted-foreground hover:text-foreground">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
@@ -154,6 +159,52 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Public/Private Explainer Modal -->
|
||||
@if (showPublicExplainer()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Organization Visibility</span>
|
||||
</div>
|
||||
<button (click)="showPublicExplainer.set(false)" class="btn-ghost btn-sm p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-accent/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground mb-1">Public Organization</h4>
|
||||
<p class="font-mono text-sm text-muted-foreground">Anyone can view this organization and its public repositories. Useful for open-source projects or public packages.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground mb-1">Private Organization</h4>
|
||||
<p class="font-mono text-sm text-muted-foreground">Only organization members can see this organization and access its repositories. Best for internal or proprietary packages.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end">
|
||||
<button (click)="showPublicExplainer.set(false)" class="btn-primary btn-md">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
@@ -164,6 +215,7 @@ export class OrganizationsComponent implements OnInit {
|
||||
organizations = signal<IOrganization[]>([]);
|
||||
loading = signal(true);
|
||||
showCreateModal = signal(false);
|
||||
showPublicExplainer = signal(false);
|
||||
creating = signal(false);
|
||||
|
||||
newOrg = {
|
||||
|
||||
@@ -102,8 +102,8 @@ interface IScopeEntry {
|
||||
|
||||
<!-- Create Modal -->
|
||||
@if (showCreateModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 overflow-y-auto py-8">
|
||||
<div class="card w-full max-w-2xl mx-4">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 overflow-y-auto py-8 modal-backdrop">
|
||||
<div class="card w-full max-w-2xl mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
@@ -284,8 +284,8 @@ interface IScopeEntry {
|
||||
|
||||
<!-- Token Created Modal -->
|
||||
@if (createdToken()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div class="card w-full max-w-lg mx-4">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-lg mx-4 modal-content">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator bg-accent"></div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout',
|
||||
@@ -65,6 +64,20 @@ import { ToastService } from '../../../core/services/toast.service';
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
<!-- Admin Section -->
|
||||
@if (isAdmin()) {
|
||||
<div class="pt-4 mt-4 border-t border-border">
|
||||
<p class="px-3 mb-2 font-mono text-xs text-muted-foreground uppercase tracking-wider">Administration</p>
|
||||
<a routerLink="/admin/auth" routerLinkActive="bg-primary/10 text-primary"
|
||||
class="nav-link">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Authentication
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- User section -->
|
||||
@@ -108,6 +121,7 @@ export class LayoutComponent {
|
||||
const name = this.authService.user()?.displayName || 'U';
|
||||
return name.charAt(0).toUpperCase();
|
||||
});
|
||||
isAdmin = computed(() => this.authService.isAdmin());
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
|
||||
@@ -259,4 +259,44 @@
|
||||
.status-error {
|
||||
@apply bg-destructive;
|
||||
}
|
||||
|
||||
/* Modal animations */
|
||||
.modal-backdrop {
|
||||
@apply animate-fade-in;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply animate-modal-in;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-modal-in {
|
||||
animation: modal-in 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user