-
Notifications
You must be signed in to change notification settings - Fork 6.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
auth-backend: Encode env
in the OAuth flow into the state
parameter. Refs #1775
#1812
Conversation
`Env` passed as a separate parameter in the request might break the flow of the authorization flow. Spotify considers the `env` as a first class abstraction and use it across their application stack. To ensure both the existing flow and newer flows are supported, use the `state` parameter to encode an object, consisting of the `nonce` and the `env` which is then passed to the authorization server. This parameter is returned to the application in the callbackURL
The `state` was modified to be an encoded object of `nonce` and `env`. When verifying the Nonce value in the callback from the authorization server, parse the state parameter string to read the right value of nonce
Update OAuthProvider tests to ensure that changes to nonce and environment handling do not cause regressions
There are a number of linting errors, you may want to set up your editor to run prettier automatically. |
/* Return the value of `env` key, if it is exists, encoded within | ||
the `state` parameter in the request | ||
*/ | ||
private getEnv(stateParams: Array<string>): string { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not used?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will remove it
/* Return the value of `env` key, if it is exists, encoded within | ||
the `state` parameter in the request | ||
*/ | ||
const readStateParameter = ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure about all of this manual string fiddling - can't the state be base64 of the json encoded state object or something, and make a proper Typescript type for the state so we can use it more cleanly?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I think turning the state into a type makes sense.
I admit that the serializing is messy. I am just getting started with Typescript and my code wont be idiomatic. Will try to find a simple way to serialize and deserialize it.
export const verifyNonce = (req: express.Request, providerId: string) => { | ||
const cookieNonce = req.cookies[`${providerId}-nonce`]; | ||
const stateNonce = req.query.state; | ||
const state: Array<string> = readState(req.query.state?.toString() ?? ''); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, I feel this should just be a proper object type with named fields. Also is the toString necessary here?
@freben : I noticed that the build failed due to a linting error |
445c106
to
e02d2a9
Compare
Create a new `OAuthState` type for storing the fields we want in the `state` parameter. Update the `encodeState` and `readState` fns to use the new `OAuthState` type. Update tests to reflect the new type
@freben , I fixed the lint/type check errors and refactored the code. Can you take a look at the updated changes ? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! 😁
}; | ||
|
||
export const encodeState = (state: OAuthState): string => { | ||
return encodeURIComponent(JSON.stringify(state)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tbh I prefer query param encoding twice over JSON. The JSON delimiters end up really ugly and take a lot of space. It would actually be more compact to url-safe base64 encode this.
e.g.
const encoded = encodeURIComponent(new URLSearchParams(state).toString());
const decoded = Object.fromEntries(new URLSearchParams(decodeURIComponent(encoded)));
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For comparison it's
env%3Ddevelopment%26nonce%3D45c8g7yn452v4v
vs
%7B%22env%22%3A%22development%22%2C%22nonce%22%3A%2245c8g7yn452v4v%22%7D
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 for this suggestion. Will update it.
export type OAuthState = { | ||
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow | ||
*/ | ||
nonce?: string; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 for adding this type. I think these should both be required right, i.e. no ?
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IIRC, I did this because the type-checker wasn't allowing me to set nonce
and env
to null if the de-serialization fails. I then check if these fields are empty when using the OAuthState
. While nonce
is mandatory, I think env
can be optional should other users of backstage have a different way of identifying the env
(or completely ignoring it).
How would you recommend having a filed that could be empty/null ? is a string | null
type better instead of making a field optional ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With them required you can reject the request early if they're not set, and then the rest of the code can be sure that they're there. I see the case for env
being optional, but since we don't have that atm, perhaps we could keep it required for now at least?
Regarding making things optional this is the way to go usually. Don't use null
😁.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also , there seems to be an issue with Error handling. If nonce
or env
were null and an Exception were thrown during parsing, the rest of the code was continued to be called instead of aborting the request processing flow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the error handling might look a bit weird in the frame handler endpoint. For the error to reach the user we actually still need to return a 200 response with a script that posts to the parent frame, but the posted payload is the error instead of an auth response.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Understood. I removed the optional fields so that the values are no longer null. Please let review the changes again :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A bunch of comments around the usage of empty strings 😅 . So by using empty strings we essentially opt-out of type checking, since it's not a correct value at runtime, but it conforms to the type string
. We want to really avoid ever assigning an empty string when working with the nonce
and env
, as those should always be defined.
The other aspect of it is to fail as early as possible if something's wrong. It makes it easier to debug issues and likely wastes less time of the user 😁. An example is allowing an empty env
param in the initial auth request but then failing in the auth handler. At that point the user has already gone through the process of signing in, when we could've just thrown an error in the initial request.
new URLSearchParams(decodeURIComponent(stateString)), | ||
); | ||
return { | ||
nonce: state.nonce ?? '', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Best not assign any empty strings here, it's essentially makes these optional values but without type checking.
I think it makes sense to check if both state.nonce
and state.env
are present and non-empty strings here, and throw appropriate errors otherwise.
|
||
if (!cookieNonce) { | ||
throw new Error('Auth response is missing cookie nonce'); | ||
} | ||
if (!stateNonce) { | ||
if (stateNonce.length === 0) { | ||
throw new Error('Auth response is missing state nonce'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This error could for example be moved in and thrown from the readState
function instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. I moved the empty string check into the readState
function.
@@ -103,6 +130,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers { | |||
async start(req: express.Request, res: express.Response): Promise<void> { | |||
// retrieve scopes from request | |||
const scope = req.query.scope?.toString() ?? ''; | |||
const env = req.query.env?.toString() ?? ''; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same goes for this. If no env
is set we want to fail early and throw an InputError
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
start now throws an Error if env
is empty or missing.
return ''; | ||
} | ||
const env = readState(stateParams).env; | ||
return env ?? ''; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Best to return undefined
here and above as well
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated.
@@ -106,6 +106,10 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { | |||
async logout(_req: express.Request, res: express.Response): Promise<void> { | |||
res.send('noop'); | |||
} | |||
|
|||
identifyEnv(): string { | |||
return ''; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
undefined
here too
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated.
Type fn that identifies the env (environment) of a Request Fail early if `env` is not present in the request or has an invalid `env` in the state
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, thanks again! 👍
@@ -153,7 +153,7 @@ export function createOAuth2Provider( | |||
const appOrigin = envConfig.getString('appOrigin'); | |||
const clientID = envConfig.getString('clientId'); | |||
const clientSecret = envConfig.getString('clientSecret'); | |||
const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Realized this only removes the param from okta, I think we can remove it from all the OAuth providers?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't notice it either. Yes, will remove the env parameter from the callbackURL of other providers also.
Just tried this out since I wasn't sure if it's a breaking change or not. It definitely is for Google Auth, and probably others too. Depends on how picky they are about the redirect URL. Was looking into methods to display a clear error message but we're not really in the request path when the error happens. Usually providers display good error messages, but there might be some confusion about why the current configuration is not working all of a sudden. I'll look into getting some kind of basic changelog going so we can highlight some of these breaking changes. |
@Rugvip 👍 on checking with Google.
Sounds great ! |
Changelog PR here: #1845, we can add an entry after this is merged though |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's , followup doc PR would be awesome tho!
env
param in the OAuth request flow is now encoded in the state parameter.The
env
parameter used to select the rightEnvironmentHandlers
is an explicit parameter that might break the flow for Users of backstage. To solve this issue, theenv
and thenonce
parameter are encoded in thestate
parameter of the request.Previously, the
state
parameter contained only thenonce
used for preventing CSRF attacks. This change will create an object with thenonce
andenv
as keys in it and URL encode the object before setting it as the value of thestate
parameter.✔️ Checklist
yarn test