mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
When you click on a saved account (like "admin@example.com"), the login form is automatically submitted. But if you then also click the "Sign In" button (or click the account a second time), a second login attempt is sent, even though the first one already completed. The second attempt then fails because the login session it's trying to use is already gone, causing the "Session not found" error. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved form submission handling on the login page to prevent duplicate sign-in attempts from rapid user actions or accidental double-clicks. The form now ensures only one submission occurs per session. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
549 lines
19 KiB
TypeScript
549 lines
19 KiB
TypeScript
import { strict as assert } from 'assert';
|
|
import express from 'express';
|
|
import handlebars from 'handlebars';
|
|
import Provider, { errors } from 'oidc-provider';
|
|
|
|
const stackPortPrefix = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81";
|
|
const defaultMockOAuthPort = Number(`${stackPortPrefix}14`);
|
|
const port = Number(process.env.PORT ?? defaultMockOAuthPort);
|
|
const backendPortForRedirects = `${stackPortPrefix}02`;
|
|
const emulatorBackendPort = process.env.STACK_EMULATOR_BACKEND_PORT ?? "32102";
|
|
const providerIds = [
|
|
'github',
|
|
'facebook',
|
|
'google',
|
|
'microsoft',
|
|
'spotify',
|
|
'discord',
|
|
'gitlab',
|
|
'bitbucket',
|
|
'x',
|
|
];
|
|
const clients = providerIds.map((id) => ({
|
|
client_id: id,
|
|
client_secret: 'MOCK-SERVER-SECRET',
|
|
redirect_uris: [
|
|
`http://localhost:${backendPortForRedirects}/api/v1/auth/oauth/callback/${id}`,
|
|
...(process.env.STACK_MOCK_OAUTH_REDIRECT_URIS ? [process.env.STACK_MOCK_OAUTH_REDIRECT_URIS.replace("{id}", id)] : [])
|
|
],
|
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
}));
|
|
|
|
const configuration = {
|
|
clients,
|
|
ttl: { Session: 60 },
|
|
findAccount: async (ctx: any, sub: string) => ({
|
|
accountId: sub,
|
|
async claims() {
|
|
return { sub, email: sub };
|
|
},
|
|
})
|
|
};
|
|
|
|
const oidc = new Provider(`http://localhost:${port}`, configuration);
|
|
const app = express();
|
|
|
|
// Simple in-memory storage for revoked tokens
|
|
const revokedTokens = new Set<string>();
|
|
|
|
// Storage for simulating specific error responses on token refresh
|
|
// Maps refresh token -> error type to return on next refresh attempt
|
|
const simulatedRefreshErrors = new Map<string, { error: string, error_description: string }>();
|
|
|
|
// Storage for simulating errors by grant ID (since we can't easily get refresh tokens)
|
|
const simulatedRefreshErrorsByGrant = new Map<string, { error: string, error_description: string }>();
|
|
|
|
// These prefixes must match mockTurnstileTokens in apps/e2e/tests/backend/backend-helpers.ts
|
|
function getMockTurnstileVerificationResponse(token: unknown): {
|
|
statusCode: number,
|
|
body: {
|
|
success: boolean,
|
|
action?: string,
|
|
},
|
|
} | null {
|
|
if (typeof token !== "string" || token.trim() === "") {
|
|
return null;
|
|
}
|
|
const normalizedToken = token.trim();
|
|
|
|
if (normalizedToken === "mock-turnstile-error") {
|
|
return {
|
|
statusCode: 503,
|
|
body: {
|
|
success: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (normalizedToken === "mock-turnstile-invalid") {
|
|
return {
|
|
statusCode: 200,
|
|
body: {
|
|
success: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
for (const prefix of ["mock-turnstile-ok:", "mock-turnstile-visible-ok:"]) {
|
|
if (normalizedToken.startsWith(prefix)) {
|
|
return {
|
|
statusCode: 200,
|
|
body: {
|
|
success: true,
|
|
action: normalizedToken.slice(prefix.length),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
app.use(express.urlencoded({ extended: false }));
|
|
app.use(express.json()); // Add JSON parsing middleware
|
|
|
|
// Local-only Turnstile stub so tests still exercise the HTTP siteverify boundary without a backend bypass.
|
|
// Only mock tokens (prefixed with "mock-turnstile-") are handled here. For real tokens, configure the
|
|
// STACK_TURNSTILE_SITEVERIFY_URL envvar to point directly to Cloudflare instead of this mock server.
|
|
app.post('/turnstile/siteverify', async (req: express.Request, res: express.Response) => {
|
|
const token = req.body.response;
|
|
if (typeof token !== "string" || token.trim() === "") {
|
|
res.status(400).json({
|
|
success: false,
|
|
"error-codes": ["missing-input-response"],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const verification = getMockTurnstileVerificationResponse(token);
|
|
if (verification) {
|
|
res.status(verification.statusCode).json(verification.body);
|
|
return;
|
|
}
|
|
|
|
// In development, non-mock tokens come from Cloudflare's development site keys.
|
|
// Always return success for these so the Turnstile flow doesn't block local development.
|
|
res.status(200).json({
|
|
success: true,
|
|
});
|
|
});
|
|
|
|
// Middleware to intercept token refresh requests and return simulated errors
|
|
app.post('/token', async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
if (req.body.grant_type === 'refresh_token' && req.body.refresh_token) {
|
|
const refreshTokenValue = req.body.refresh_token;
|
|
|
|
// Check by refresh token directly
|
|
const simulatedError = simulatedRefreshErrors.get(refreshTokenValue);
|
|
if (simulatedError) {
|
|
simulatedRefreshErrors.delete(refreshTokenValue);
|
|
res.status(400).json(simulatedError);
|
|
return;
|
|
}
|
|
|
|
// Check by grant ID
|
|
try {
|
|
const refreshToken = await oidc.RefreshToken.find(refreshTokenValue);
|
|
if (refreshToken?.grantId) {
|
|
const errorByGrant = simulatedRefreshErrorsByGrant.get(refreshToken.grantId);
|
|
if (errorByGrant) {
|
|
simulatedRefreshErrorsByGrant.delete(refreshToken.grantId);
|
|
res.status(400).json(errorByGrant);
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// Token might not be found, continue to oidc-provider
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
|
|
const loginTemplateSource = `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Sign-in</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
body {
|
|
background-color: #f8f9fa;
|
|
}
|
|
.card {
|
|
background-color: #fff;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen flex items-center justify-center p-4">
|
|
<div class="card w-full max-w-md p-8">
|
|
<h1 class="text-2xl font-bold mb-6 text-center">Mock OAuth Sign-in</h1>
|
|
<p class="text-gray-500 mb-4 text-center">This is a mock OAuth server for testing. It accepts any email without a password.</p>
|
|
<form method="post" action="/interaction/{{uid}}/login" class="space-y-4">
|
|
<div>
|
|
<label for="login" class="block text-gray-700">Email</label>
|
|
<input id="login" type="email" name="login" required placeholder="eg.: email@example.com"
|
|
class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring focus:border-blue-300" />
|
|
</div>
|
|
<button type="submit"
|
|
class="w-full bg-black hover:bg-gray-800 text-white font-semibold py-2 px-4 rounded">
|
|
Sign in
|
|
</button>
|
|
</form>
|
|
<!-- Container for displaying stored account emails -->
|
|
<div id="stored-accounts" class="mt-4"></div>
|
|
<details class="mt-6 bg-gray-50 rounded p-2">
|
|
<summary class="cursor-pointer text-sm text-gray-600">Debug</summary>
|
|
<pre class="mt-1 text-xs text-gray-500 overflow-x-auto">{{debugInfo}}</pre>
|
|
</details>
|
|
<script>
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const storedAccountsContainer = document.getElementById('stored-accounts');
|
|
const emailInput = document.getElementById('login');
|
|
if (!storedAccountsContainer || !emailInput) return;
|
|
|
|
// Retrieve stored accounts from localStorage or initialize as an empty array
|
|
let storedAccounts = JSON.parse(localStorage.getItem('previousAccounts') || '[]');
|
|
|
|
// Get the form element to submit later
|
|
const form = document.querySelector('form');
|
|
if (!form) return;
|
|
let submitted = false;
|
|
const submitOnce = () => {
|
|
if (submitted) return;
|
|
form.requestSubmit();
|
|
};
|
|
|
|
// Render the list of stored accounts and add direct submission on click.
|
|
const renderStoredAccounts = () => {
|
|
if (storedAccounts.length > 0) {
|
|
let listHtml = '<h2 class="text-lg font-medium text-gray-700 mb-2">Previously Used Accounts</h2>';
|
|
listHtml += '<div class="grid gap-2">';
|
|
storedAccounts.forEach((account) => {
|
|
listHtml += \`
|
|
<div class="p-3 bg-white border border-gray-200 rounded-lg shadow-sm hover:bg-gray-50 transition-shadow cursor-pointer" data-email="\${account}">
|
|
<div class="flex items-center">
|
|
<div class="h-8 w-8 rounded-full bg-gray-100 flex items-center justify-center mr-3">
|
|
<span class="text-gray-600 font-medium">\${account.charAt(0).toUpperCase()}</span>
|
|
</div>
|
|
<span class="text-gray-700">\${account}</span>
|
|
</div>
|
|
</div>
|
|
\`;
|
|
});
|
|
listHtml += '</div>';
|
|
storedAccountsContainer.innerHTML = listHtml;
|
|
|
|
// Add click event listeners that set the email and submit the form directly.
|
|
storedAccountsContainer.querySelectorAll('[data-email]').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
const selectedEmail = card.getAttribute('data-email') || '';
|
|
emailInput.value = selectedEmail;
|
|
submitOnce();
|
|
});
|
|
});
|
|
} else {
|
|
storedAccountsContainer.innerHTML = '';
|
|
}
|
|
};
|
|
|
|
renderStoredAccounts();
|
|
|
|
form.addEventListener('submit', (e) => {
|
|
if (submitted) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
submitted = true;
|
|
const email = emailInput.value.trim();
|
|
if (email && !storedAccounts.includes(email)) {
|
|
storedAccounts.push(email);
|
|
localStorage.setItem('previousAccounts', JSON.stringify(storedAccounts));
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const loginTemplate = handlebars.compile(loginTemplateSource);
|
|
|
|
const renderLoginView = ({ uid, debugInfo }: { uid: string, debugInfo: string }): string => {
|
|
return loginTemplate({ uid, debugInfo });
|
|
};
|
|
|
|
const setNoCache = (req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
|
res.set('cache-control', 'no-store');
|
|
next();
|
|
};
|
|
|
|
app.get('/interaction/:uid', setNoCache, async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
try {
|
|
const { uid, prompt, params, session, grantId } = await oidc.interactionDetails(req, res);
|
|
const debugInfo = JSON.stringify({ params, prompt, session }, null, 2);
|
|
|
|
if (prompt.name === 'login') {
|
|
res.send(renderLoginView({
|
|
uid,
|
|
debugInfo,
|
|
}));
|
|
} else if (prompt.name === 'consent') {
|
|
// Automatically approve consent without showing an approval page.
|
|
if (!session) throw new Error('No session found');
|
|
const accountId = session.accountId;
|
|
const { details } = prompt;
|
|
|
|
let grant = grantId
|
|
? await oidc.Grant.find(grantId)
|
|
: new oidc.Grant({ accountId, clientId: params.client_id as string });
|
|
if (!grant) {
|
|
throw new Error('Failed to create or find grant');
|
|
}
|
|
if (Array.isArray(details.missingOIDCScope)) {
|
|
grant.addOIDCScope(details.missingOIDCScope.join(' '));
|
|
}
|
|
if (Array.isArray(details.missingOIDCClaims)) {
|
|
grant.addOIDCClaims(details.missingOIDCClaims);
|
|
}
|
|
if (details.missingResourceScopes && typeof details.missingResourceScopes === 'object') {
|
|
for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
|
|
if (Array.isArray(scopes)) {
|
|
grant.addResourceScope(indicator, scopes.join(' '));
|
|
}
|
|
}
|
|
}
|
|
const newGrantId = await grant.save();
|
|
const consent: { grantId?: string } = {};
|
|
if (!grantId) consent.grantId = newGrantId;
|
|
const result = { consent };
|
|
await oidc.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
|
|
} else {
|
|
res.send('Unknown prompt');
|
|
}
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.post('/interaction/:uid/login', setNoCache, async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
try {
|
|
const { prompt } = await oidc.interactionDetails(req, res);
|
|
assert.strictEqual(prompt.name, 'login', 'Expected login prompt');
|
|
const result = { login: { accountId: req.body.login, remember: false } };
|
|
await oidc.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// Endpoint to simulate specific OAuth errors on next refresh attempt
|
|
// This is useful for testing how the backend handles various OAuth error scenarios
|
|
app.post('/simulate-refresh-error', async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
try {
|
|
const { token, error_type } = req.body;
|
|
|
|
if (!token) {
|
|
res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'Missing token parameter'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Find the access token to get the associated refresh token
|
|
const accessToken = await oidc.AccessToken.find(token);
|
|
if (!accessToken) {
|
|
res.status(400).json({
|
|
error: 'invalid_token',
|
|
error_description: 'Access token not found'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Get the refresh token associated with this grant
|
|
const grantId = accessToken.grantId;
|
|
if (!grantId) {
|
|
res.status(400).json({
|
|
error: 'invalid_token',
|
|
error_description: 'No grant associated with this token'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Find refresh tokens for this grant
|
|
// Note: oidc-provider stores refresh tokens with the grantId, but we need to find them
|
|
// For simplicity, we'll store the error by grantId and check it in the middleware
|
|
const errorResponses: Record<string, { error: string, error_description: string } | undefined> = {
|
|
'invalid_grant': { error: 'invalid_grant', error_description: 'The refresh token is invalid or expired' },
|
|
'access_denied': { error: 'access_denied', error_description: 'The resource owner denied the request' },
|
|
'consent_required': { error: 'consent_required', error_description: 'User consent is required' },
|
|
'invalid_token': { error: 'invalid_token', error_description: 'The token is invalid' },
|
|
'unauthorized_client': { error: 'unauthorized_client', error_description: 'The client is not authorized' },
|
|
};
|
|
|
|
const errorResponse = errorResponses[error_type];
|
|
if (!errorResponse) {
|
|
res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: `Invalid error_type. Valid types: ${Object.keys(errorResponses).join(', ')}`
|
|
});
|
|
return;
|
|
}
|
|
|
|
// We need to find the refresh token. Since oidc-provider doesn't expose a simple way to do this,
|
|
// we'll store by grantId and update the middleware to check grantIds
|
|
// For now, let's use a workaround: store by grantId
|
|
simulatedRefreshErrorsByGrant.set(grantId, errorResponse);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Next refresh attempt for this token will return ${error_type} error`
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
error: 'server_error',
|
|
error_description: 'Failed to set up simulated error'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Token revocation endpoints
|
|
app.post('/revoke-refresh-token', async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
try {
|
|
const { token } = req.body;
|
|
|
|
if (!token) {
|
|
res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'Missing token parameter'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Find the access token first
|
|
try {
|
|
const accessToken = await oidc.AccessToken.find(token);
|
|
if (!accessToken) {
|
|
res.status(400).json({
|
|
error: 'invalid_token',
|
|
error_description: 'Access token not found'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Get the grant associated with this access token
|
|
const grantId = accessToken.grantId;
|
|
if (grantId) {
|
|
try {
|
|
const grant = await oidc.Grant.find(grantId);
|
|
if (grant) {
|
|
// Add access token to revoked list
|
|
revokedTokens.add(token);
|
|
|
|
// Destroy the grant which should invalidate all associated tokens including refresh tokens
|
|
await grant.destroy();
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Grant and associated refresh tokens have been revoked'
|
|
});
|
|
return;
|
|
}
|
|
} catch (grantErr) {
|
|
// Fall through to alternative approach if grant destruction fails
|
|
}
|
|
}
|
|
|
|
// Fallback: Add access token to revoked list
|
|
revokedTokens.add(token);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Access token marked as revoked (refresh token association not found)'
|
|
});
|
|
} catch (err) {
|
|
// Alternative approach - just mark the access token as revoked
|
|
revokedTokens.add(token);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Token marked as revoked'
|
|
});
|
|
}
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
error: 'server_error',
|
|
error_description: 'Failed to revoke refresh token'
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/revoke-access-token', async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
try {
|
|
const { token } = req.body;
|
|
|
|
if (!token) {
|
|
res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'Missing token parameter'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Add token to revoked list
|
|
revokedTokens.add(token);
|
|
|
|
// Try to find and revoke the token using oidc-provider's built-in functionality
|
|
try {
|
|
const accessToken = await oidc.AccessToken.find(token);
|
|
if (accessToken) {
|
|
await accessToken.destroy();
|
|
}
|
|
} catch (err) {
|
|
// Token might not exist or already be expired, but we still add it to our blacklist
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Access token has been revoked'
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
error: 'server_error',
|
|
error_description: 'Failed to revoke access token'
|
|
});
|
|
}
|
|
});
|
|
|
|
// The POST consent route has been removed as consent is now auto-approved.
|
|
app.get('/interaction/:uid/abort', setNoCache, async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
try {
|
|
const result = {
|
|
error: 'access_denied',
|
|
error_description: 'End-User aborted interaction',
|
|
};
|
|
await oidc.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
|
if (err instanceof errors.SessionNotFound) {
|
|
res.status(410).send('Session not found or expired');
|
|
} else {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.use(oidc.callback());
|
|
|
|
app.listen(port, () => {
|
|
console.log(`Server is running on http://localhost:${port}`);
|
|
});
|