Skip to content

Commit b010d89

Browse files
committed
[server] Enforce state presence and validation on the /api/authorize endoint
1 parent c0a7be3 commit b010d89

File tree

4 files changed

+75
-2
lines changed

4 files changed

+75
-2
lines changed

components/server/src/auth/authenticator.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { UserService } from "../user/user-service";
1818
import { AuthFlow, AuthProvider } from "./auth-provider";
1919
import { HostContextProvider } from "./host-context-provider";
2020
import { SignInJWT } from "./jwt";
21+
import { getFeatureFlagEnforceAuthorizeStateValidation } from "../util/featureflags";
2122

2223
@injectable()
2324
export class Authenticator {
@@ -121,6 +122,60 @@ export class Authenticator {
121122
return state;
122123
}
123124

125+
private async validateOAuthState(
126+
userId: string,
127+
stateParam: string | undefined,
128+
host: string | undefined,
129+
returnTo: string | undefined,
130+
): Promise<boolean> {
131+
// Check feature flag for OAuth state validation
132+
const enforceStateValidation = await getFeatureFlagEnforceAuthorizeStateValidation(userId);
133+
134+
if (!enforceStateValidation) {
135+
log.info(`OAuth state validation disabled by feature flag`, {
136+
host,
137+
returnTo,
138+
"authorize-flow": true,
139+
});
140+
return true; // Allow request to proceed
141+
}
142+
143+
// Validate JWT state parameter when feature flag is enabled
144+
if (!stateParam) {
145+
log.warn(`Missing state parameter in authorize request`, {
146+
host,
147+
returnTo,
148+
"authorize-flow": true,
149+
});
150+
return false;
151+
}
152+
153+
try {
154+
const flowState = await this.parseState(stateParam);
155+
156+
if (flowState.host !== host || flowState.returnTo !== returnTo) {
157+
log.warn(`State parameter mismatch in authorize request`, {
158+
stateHost: flowState.host,
159+
requestHost: host,
160+
stateReturnTo: flowState.returnTo,
161+
requestReturnTo: returnTo,
162+
"authorize-flow": true,
163+
});
164+
return false;
165+
}
166+
167+
log.info(`Valid state parameter in authorize request`, { host, "authorize-flow": true });
168+
return true; // Validation passed
169+
} catch (error) {
170+
log.warn(`Invalid state parameter in authorize request`, error, {
171+
host,
172+
returnTo,
173+
"authorize-flow": true,
174+
});
175+
return false;
176+
}
177+
}
178+
124179
protected async getAuthProviderForHost(host: string): Promise<AuthProvider | undefined> {
125180
const hostContext = this.hostContextProvider.get(host);
126181
return hostContext && hostContext.authProvider;
@@ -232,6 +287,15 @@ export class Authenticator {
232287
const host: string | undefined = req.query.host?.toString();
233288
const scopes: string = req.query.scopes?.toString() || "";
234289
const override = req.query.override === "true";
290+
const stateParam = req.query.state?.toString();
291+
292+
// Validate OAuth state parameter (feature-flagged)
293+
const isStateValid = await this.validateOAuthState(user.id, stateParam, host, returnTo);
294+
if (!isStateValid) {
295+
res.redirect(this.getSorryUrl(`Invalid authorization request.`));
296+
return;
297+
}
298+
235299
const authProvider = host && (await this.getAuthProviderForHost(host));
236300
if (!returnTo || !host || !authProvider) {
237301
log.info(`Bad request: missing parameters.`, { "authorize-flow": true });

components/server/src/auth/generic-auth-provider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ export abstract class GenericAuthProvider implements AuthProvider {
460460
user: newUser,
461461
returnToUrl: authFlow.returnTo,
462462
authHost: authFlow.host,
463+
originalState: `${state}`,
463464
});
464465
} else {
465466
const { user, elevateScopes } = flowContext as VerifyResult.WithUser;
@@ -492,6 +493,7 @@ export abstract class GenericAuthProvider implements AuthProvider {
492493
returnToUrl: returnTo,
493494
authHost: host,
494495
elevateScopes,
496+
originalState: `${state}`,
495497
});
496498
}
497499
}

components/server/src/auth/login-completion-handler.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class LoginCompletionHandler {
3535
async complete(
3636
request: express.Request,
3737
response: express.Response,
38-
{ user, returnToUrl, authHost, elevateScopes }: LoginCompletionHandler.CompleteParams,
38+
{ user, returnToUrl, authHost, elevateScopes, originalState }: LoginCompletionHandler.CompleteParams,
3939
) {
4040
const logContext = LogContext.from({ user, request });
4141

@@ -64,7 +64,7 @@ export class LoginCompletionHandler {
6464
pathname: "/authorize",
6565
search: `returnTo=${encodeURIComponent(returnTo)}&host=${authHost}&scopes=${elevateScopes.join(
6666
",",
67-
)}`,
67+
)}&state=${encodeURIComponent(originalState)}`,
6868
})
6969
.toString();
7070
returnTo = elevateScopesUrl;
@@ -137,5 +137,6 @@ export namespace LoginCompletionHandler {
137137
returnToUrl?: string;
138138
authHost?: string;
139139
elevateScopes?: string[];
140+
originalState: string;
140141
}
141142
}

components/server/src/util/featureflags.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ export async function getFeatureFlagEnableExperimentalJBTB(userId: string): Prom
1111
user: { id: userId },
1212
});
1313
}
14+
15+
export async function getFeatureFlagEnforceAuthorizeStateValidation(userId: string): Promise<boolean> {
16+
return getExperimentsClientForBackend().getValueAsync("enforceAuthorizeStateValidation", false, {
17+
user: { id: userId },
18+
});
19+
}

0 commit comments

Comments
 (0)