mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
270 lines
8.0 KiB
TypeScript
270 lines
8.0 KiB
TypeScript
import Provider, { type AccountClaims, type FindAccount, type Configuration } from 'oidc-provider';
|
|
|
|
const ISSUER = 'http://localhost:9090';
|
|
const CLIENT_ID = 'survey-app';
|
|
const CLIENT_SECRET = 'test-client-secret';
|
|
|
|
interface TestUser {
|
|
password: string;
|
|
claims: AccountClaims & { groups: string[] };
|
|
}
|
|
|
|
const TEST_USERS: Record<string, TestUser> = {
|
|
'admin@test.com': {
|
|
password: 'testpassword',
|
|
claims: {
|
|
sub: 'test-admin-1',
|
|
email: 'admin@test.com',
|
|
email_verified: true,
|
|
name: 'Test Admin',
|
|
groups: ['survey-admin'],
|
|
},
|
|
},
|
|
'editor@test.com': {
|
|
password: 'testpassword',
|
|
claims: {
|
|
sub: 'test-editor-1',
|
|
email: 'editor@test.com',
|
|
email_verified: true,
|
|
name: 'Test Editor',
|
|
groups: ['survey-editor'],
|
|
},
|
|
},
|
|
'viewer@test.com': {
|
|
password: 'testpassword',
|
|
claims: {
|
|
sub: 'test-viewer-1',
|
|
email: 'viewer@test.com',
|
|
email_verified: true,
|
|
name: 'Test Viewer',
|
|
groups: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
const findAccount: FindAccount = async (_ctx, id) => {
|
|
const entry = Object.entries(TEST_USERS).find(([, u]) => u.claims.sub === id);
|
|
if (!entry) {return undefined;}
|
|
const [, user] = entry;
|
|
return {
|
|
accountId: user.claims.sub,
|
|
async claims() {
|
|
return user.claims;
|
|
},
|
|
};
|
|
};
|
|
|
|
function authenticateUser(login: string, password: string) {
|
|
const user = TEST_USERS[login];
|
|
if (!user || user.password !== password) {return undefined;}
|
|
return { accountId: user.claims.sub };
|
|
}
|
|
|
|
const configuration: Configuration = {
|
|
clients: [
|
|
{
|
|
client_id: CLIENT_ID,
|
|
client_secret: CLIENT_SECRET,
|
|
redirect_uris: [
|
|
'http://localhost:8787/api/auth/callback',
|
|
'http://localhost:5173/api/auth/callback',
|
|
'http://localhost:3000/api/auth/callback',
|
|
'http://localhost:4444/api/auth/callback',
|
|
],
|
|
grant_types: ['authorization_code'],
|
|
response_types: ['code'],
|
|
token_endpoint_auth_method: 'client_secret_post',
|
|
},
|
|
],
|
|
findAccount,
|
|
claims: {
|
|
openid: ['sub'],
|
|
email: ['email', 'email_verified'],
|
|
profile: ['name', 'groups'],
|
|
},
|
|
scopes: ['openid', 'email', 'profile'],
|
|
features: {
|
|
devInteractions: { enabled: false },
|
|
},
|
|
conformIdTokenClaims: true,
|
|
pkce: {
|
|
required: () => false,
|
|
},
|
|
interactions: {
|
|
url(_ctx, interaction) {
|
|
return `/interaction/${interaction.uid}`;
|
|
},
|
|
},
|
|
cookies: {
|
|
keys: ['oidc-test-secret-key-1'],
|
|
},
|
|
// Let oidc-provider generate keys at startup (avoids Node.js v24 crypto compat issues with static keys)
|
|
};
|
|
|
|
export async function startOidcServer(): Promise<{
|
|
server: ReturnType<Provider['listen']>;
|
|
stop: () => Promise<void>;
|
|
}> {
|
|
const provider = new Provider(ISSUER, configuration);
|
|
|
|
// Middleware added with provider.use() runs before oidc-provider's own routes.
|
|
provider.use(async (ctx, next) => {
|
|
const url = ctx.URL;
|
|
|
|
const interactionGetMatch = url.pathname.match(/^\/interaction\/([^/]+)$/);
|
|
if (interactionGetMatch && ctx.method === 'GET') {
|
|
const uid = interactionGetMatch[1];
|
|
try {
|
|
const details = await provider.interactionDetails(ctx.req, ctx.res);
|
|
if (details.prompt.name === 'login') {
|
|
ctx.type = 'text/html';
|
|
ctx.body = `<!DOCTYPE html>
|
|
<html><body>
|
|
<form method="post" action="/interaction/${uid}/login">
|
|
<input name="login" placeholder="Email" type="text" />
|
|
<input name="password" placeholder="Password" type="password" />
|
|
<button type="submit">Sign in</button>
|
|
</form>
|
|
</body></html>`;
|
|
return;
|
|
}
|
|
if (details.prompt.name === 'consent') {
|
|
ctx.type = 'text/html';
|
|
ctx.body = `<!DOCTYPE html>
|
|
<html><body>
|
|
<form method="post" action="/interaction/${uid}/confirm">
|
|
<p>Authorize this application?</p>
|
|
<button type="submit">Authorize</button>
|
|
</form>
|
|
</body></html>`;
|
|
return;
|
|
}
|
|
} catch {
|
|
ctx.status = 500;
|
|
ctx.body = 'Interaction error';
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (ctx.method === 'POST' && /^\/interaction\/[^/]+\/login$/.test(url.pathname)) {
|
|
const uid = url.pathname.split('/', 3)[2];
|
|
const body = await readBody(ctx.req);
|
|
const params = new URLSearchParams(body);
|
|
const login = params.get('login') ?? '';
|
|
const password = params.get('password') ?? '';
|
|
|
|
const account = authenticateUser(login, password);
|
|
if (!account) {
|
|
ctx.type = 'text/html';
|
|
ctx.body = `<!DOCTYPE html>
|
|
<html><body>
|
|
<p class="error">Invalid credentials</p>
|
|
<form method="post" action="/interaction/${uid}/login">
|
|
<input name="login" placeholder="Email" type="text" />
|
|
<input name="password" placeholder="Password" type="password" />
|
|
<button type="submit">Sign in</button>
|
|
</form>
|
|
</body></html>`;
|
|
return;
|
|
}
|
|
|
|
const result = { login: { accountId: account.accountId } };
|
|
await provider.interactionFinished(ctx.req, ctx.res, result, {
|
|
mergeWithLastSubmission: false,
|
|
});
|
|
// interactionFinished writes the response directly — prevent koa from overwriting
|
|
ctx.respond = false;
|
|
return;
|
|
}
|
|
|
|
if (ctx.method === 'POST' && /^\/interaction\/[^/]+\/confirm$/.test(url.pathname)) {
|
|
try {
|
|
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const {
|
|
prompt: { details: promptDetails },
|
|
params,
|
|
session,
|
|
} = interactionDetails;
|
|
const accountId = session?.accountId;
|
|
|
|
if (!accountId) {
|
|
ctx.status = 400;
|
|
ctx.body = 'No session';
|
|
return;
|
|
}
|
|
|
|
let grant = interactionDetails.grantId
|
|
? await provider.Grant.find(interactionDetails.grantId)
|
|
: new provider.Grant({ accountId, clientId: params.client_id as string });
|
|
|
|
if (!grant) {
|
|
grant = new provider.Grant({ accountId, clientId: params.client_id as string });
|
|
}
|
|
|
|
const missingOIDCScope = (promptDetails.missingOIDCScope as string[] | undefined) ?? [];
|
|
if (missingOIDCScope.length > 0) {
|
|
grant.addOIDCScope(missingOIDCScope.join(' '));
|
|
}
|
|
const missingOIDCClaims = (promptDetails.missingOIDCClaims as string[] | undefined) ?? [];
|
|
if (missingOIDCClaims.length > 0) {
|
|
grant.addOIDCClaims(missingOIDCClaims);
|
|
}
|
|
const missingResourceScopes =
|
|
(promptDetails.missingResourceScopes as Record<string, string[]> | undefined) ?? {};
|
|
for (const [indicator, scopes] of Object.entries(missingResourceScopes)) {
|
|
grant.addResourceScope(indicator, scopes.join(' '));
|
|
}
|
|
|
|
const grantId = await grant.save();
|
|
const result = { consent: { grantId } };
|
|
await provider.interactionFinished(ctx.req, ctx.res, result, {
|
|
mergeWithLastSubmission: true,
|
|
});
|
|
ctx.respond = false;
|
|
} catch {
|
|
ctx.status = 500;
|
|
ctx.body = 'Consent error';
|
|
}
|
|
return;
|
|
}
|
|
|
|
await next();
|
|
});
|
|
|
|
provider.on('server_error', (ctx: unknown, err: Error) => {
|
|
console.error('[OIDC server_error]', err.message, err.stack);
|
|
});
|
|
provider.on('grant.error', (ctx: unknown, err: Error) => {
|
|
console.error('[OIDC grant.error]', err.message);
|
|
});
|
|
|
|
const server = provider.listen(9090);
|
|
console.log('OIDC test server running on http://localhost:9090');
|
|
|
|
return {
|
|
server,
|
|
stop: () =>
|
|
new Promise<void>((resolve) => {
|
|
server.close(() => resolve());
|
|
}),
|
|
};
|
|
}
|
|
|
|
function readBody(req: import('node:http').IncomingMessage): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let data = '';
|
|
req.on('data', (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
});
|
|
req.on('end', () => resolve(data));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
// Allow running standalone: `npx tsx e2e/oidc-server.ts`
|
|
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replaceAll('\\', '/'))) {
|
|
startOidcServer().then(() => console.log('OIDC server started, press Ctrl+C to stop'));
|
|
}
|
|
|
|
export { ISSUER, CLIENT_ID, CLIENT_SECRET, TEST_USERS };
|