Dev Mock IdP
Lokálny mock OIDC server pre development bez závislosti od
auth.sportup.sk.
Prečo
auth.sportup.sk v čase písania ešte neexistuje — buduje sa v sesterskom projekte. Aby sme mohli stavať ClubUp paralelne, máme mock IdP v packages/auth/dev-idp/.
Mock simuluje:
- OIDC discovery endpoint (
/.well-known/openid-configuration) - JWKS endpoint
- Authorization endpoint (
/authorize) — auto-approve, bez prihl. obrazovky - Token endpoint (
/token) - UserInfo endpoint (
/userinfo)
Žiadny user management — vystaví fake user podľa env vars.
Spustenie
# V root repa
npm run mock-idp
# Alebo priamo
cd src/packages/auth/dev-idp
npm run startMock beží na http://localhost:9000.
Konfigurácia ClubUp pre dev
# .env.local v apps/app/
SPORTUP_OIDC_ISSUER=http://localhost:9000
SPORTUP_OIDC_CLIENT_ID=dev-client
SPORTUP_OIDC_CLIENT_SECRET=dev-secret
AUTH_SECRET=development-only-do-not-use-in-prod-aaaaaaaaaaaaaaaaaaaaaaaaaa
AUTH_URL=http://localhost:3000
# Mock-specific — kontroluje, kto sa "prihlási"
MOCK_USER_ID=sportup_person_id_dev_001
MOCK_USER_NAME=Mária Dev Testovacia
MOCK_USER_EMAIL=maria@dev.local
MOCK_USER_ROLES=clubup:student,clubup:adminMock IdP implementácia (skeleton)
// src/packages/auth/dev-idp/server.ts
import http from 'node:http';
import { URL } from 'node:url';
import { generateKeyPair, exportJWK, SignJWT, jwtVerify } from 'jose';
const PORT = 9000;
const ISSUER = `http://localhost:${PORT}`;
// Vygeneruj kľúčový pár pri štarte
const { publicKey, privateKey } = await generateKeyPair('RS256');
const jwk = await exportJWK(publicKey);
jwk.kid = 'dev-kid-1';
jwk.use = 'sig';
jwk.alg = 'RS256';
// Pseudo "user database" — z env vars
const MOCK_USER = {
sub: process.env.MOCK_USER_ID ?? 'sportup_person_id_dev_001',
name: process.env.MOCK_USER_NAME ?? 'Mária Dev Testovacia',
email: process.env.MOCK_USER_EMAIL ?? 'maria@dev.local',
email_verified: true,
picture: null,
locale: 'sk-SK',
zoneinfo: 'Europe/Bratislava',
sportup_roles: (process.env.MOCK_USER_ROLES ?? 'clubup:student').split(','),
sportup_verified_person: true,
};
// In-memory store pre auth codes
const authCodes = new Map<string, { codeChallenge: string; nonce: string; expiresAt: number }>();
http.createServer(async (req, res) => {
const url = new URL(req.url!, `http://localhost:${PORT}`);
// === Discovery ===
if (url.pathname === '/.well-known/openid-configuration') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/authorize`,
token_endpoint: `${ISSUER}/token`,
userinfo_endpoint: `${ISSUER}/userinfo`,
jwks_uri: `${ISSUER}/jwks`,
end_session_endpoint: `${ISSUER}/logout`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid', 'profile', 'email', 'sportup_roles'],
token_endpoint_auth_methods_supported: ['client_secret_post'],
claims_supported: [
'sub', 'name', 'given_name', 'family_name', 'email', 'email_verified',
'picture', 'locale', 'zoneinfo', 'sportup_roles', 'sportup_verified_person',
],
code_challenge_methods_supported: ['S256'],
}));
}
// === JWKS ===
if (url.pathname === '/jwks') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ keys: [jwk] }));
}
// === Authorize — auto-approve, no UI ===
if (url.pathname === '/authorize') {
const responseType = url.searchParams.get('response_type');
const clientId = url.searchParams.get('client_id');
const redirectUri = url.searchParams.get('redirect_uri');
const scope = url.searchParams.get('scope');
const state = url.searchParams.get('state');
const nonce = url.searchParams.get('nonce');
const codeChallenge = url.searchParams.get('code_challenge');
const codeChallengeMethod = url.searchParams.get('code_challenge_method');
if (responseType !== 'code' || !clientId || !redirectUri || !codeChallenge) {
res.writeHead(400);
return res.end('invalid_request');
}
// Generate auth code
const code = randomString(32);
authCodes.set(code, {
codeChallenge,
nonce: nonce ?? '',
expiresAt: Date.now() + 60_000, // 60s
});
// Redirect
const callback = new URL(redirectUri);
callback.searchParams.set('code', code);
if (state) callback.searchParams.set('state', state);
res.writeHead(302, { Location: callback.toString() });
return res.end();
}
// === Token ===
if (url.pathname === '/token' && req.method === 'POST') {
const body = await readBody(req);
const params = new URLSearchParams(body);
const grantType = params.get('grant_type');
const code = params.get('code');
const codeVerifier = params.get('code_verifier');
const clientId = params.get('client_id');
if (grantType !== 'authorization_code') {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'unsupported_grant_type' }));
}
const codeData = authCodes.get(code!);
if (!codeData || codeData.expiresAt < Date.now()) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'invalid_grant' }));
}
// Validate PKCE
const expectedChallenge = sha256base64url(codeVerifier!);
if (expectedChallenge !== codeData.codeChallenge) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'invalid_grant', error_description: 'pkce_mismatch' }));
}
authCodes.delete(code!);
// Generate tokens
const idToken = await new SignJWT({
...MOCK_USER,
nonce: codeData.nonce,
})
.setProtectedHeader({ alg: 'RS256', kid: 'dev-kid-1' })
.setIssuer(ISSUER)
.setAudience(clientId!)
.setSubject(MOCK_USER.sub)
.setExpirationTime('1h')
.setIssuedAt()
.sign(privateKey);
const accessToken = await new SignJWT({ scope: 'openid profile email sportup_roles' })
.setProtectedHeader({ alg: 'RS256', kid: 'dev-kid-1' })
.setIssuer(ISSUER)
.setSubject(MOCK_USER.sub)
.setExpirationTime('15m')
.setIssuedAt()
.sign(privateKey);
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 900,
id_token: idToken,
refresh_token: randomString(64),
scope: 'openid profile email sportup_roles',
}));
}
// === UserInfo ===
if (url.pathname === '/userinfo') {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
res.writeHead(401);
return res.end();
}
try {
await jwtVerify(auth.slice(7), publicKey, { issuer: ISSUER });
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify(MOCK_USER));
} catch {
res.writeHead(401);
return res.end();
}
}
// === Logout ===
if (url.pathname === '/logout') {
const postLogoutRedirect = url.searchParams.get('post_logout_redirect_uri') ?? '/';
res.writeHead(302, { Location: postLogoutRedirect });
return res.end();
}
res.writeHead(404);
res.end('not found');
}).listen(PORT, () => {
console.log(`Mock IdP running at ${ISSUER}`);
});
// Helpers
function randomString(len: number) { /* ... */ }
function sha256base64url(input: string) { /* ... */ }
function readBody(req: http.IncomingMessage): Promise<string> { /* ... */ }Multiple test users
Pre testovanie rôznych rolí môžete spustiť mock s rôznymi env vars v package.json scripts:
{
"scripts": {
"mock-idp:student": "MOCK_USER_ROLES=clubup:student npm run mock-idp",
"mock-idp:admin": "MOCK_USER_ROLES=clubup:admin,clubup:content_manager npm run mock-idp",
"mock-idp:instructor": "MOCK_USER_ROLES=clubup:instructor npm run mock-idp"
}
}Alebo dynamicky cez query param ?as=admin:
// V authorize handlere
const role = url.searchParams.get('as');
const userOverride = role === 'admin' ? { sportup_roles: ['clubup:admin'] } : {};
const userToReturn = { ...MOCK_USER, ...userOverride };Bezpečnostné upozornenie
Mock IdP NIKDY nesmie bežať v production. Ochrany:
- Mock IdP package nezahŕňame do production build (devDependencies only)
- Build-time check v
apps/app/auth.ts:if (process.env.NODE_ENV === 'production' && process.env.SPORTUP_OIDC_ISSUER?.includes('localhost')) { throw new Error('Cannot use mock IdP in production'); } - Vercel env vars pre prod sú
SPORTUP_OIDC_ISSUER=https://auth.sportup.sk
Migrácia na production
Keď auth.sportup.sk bude live:
- SportUp admin nás zaregistruje ako OIDC client
- Dostaneme
client_id,client_secret, redirect URIs whitelist - Vercel env vars zmeníme:
SPORTUP_OIDC_ISSUER=https://auth.sportup.sk SPORTUP_OIDC_CLIENT_ID=clubup-app-prod SPORTUP_OIDC_CLIENT_SECRET=<from-sportup-admin> - Mock IdP zostáva v repe pre dev workflows
- Žiadna zmena kódu — Auth.js OIDC config je generic
Test-mode users v sportup.sk
V budúcnosti auth.sportup.sk môže mať „test mode” — sandbox tenant s testovacími účtami pre QA. To by nahradilo lokálny mock IdP pre staging/preview deployments. MVP používa mock pre maximálnu nezávislosť.