Skip to content
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

Merged
merged 10 commits into from
Aug 6, 2020

Conversation

GoWind
Copy link
Contributor

@GoWind GoWind commented Aug 3, 2020

env param in the OAuth request flow is now encoded in the state parameter.

The env parameter used to select the right EnvironmentHandlers is an explicit parameter that might break the flow for Users of backstage. To solve this issue, the env and the nonce parameter are encoded in the state parameter of the request.

Previously, the state parameter contained only the nonce used for preventing CSRF attacks. This change will create an object with the nonce and env as keys in it and URL encode the object before setting it as the value of the state parameter.

✔️ Checklist

  • All tests are passing yarn test
  • Screenshots attached (for UI changes)
  • Relevant documentation updated
  • Prettier run on changed files
  • Tests added for new functionality
  • Regression tests added for bug fixes

Govindarajan Nagarajan added 4 commits August 3, 2020 15:33


`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
@GoWind GoWind requested a review from a team as a code owner August 3, 2020 16:22
@freben
Copy link
Member

freben commented Aug 3, 2020

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used?

Copy link
Contributor Author

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 = (
Copy link
Member

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?

Copy link
Contributor Author

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() ?? '');
Copy link
Member

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?

@GoWind
Copy link
Contributor Author

GoWind commented Aug 4, 2020

@freben : I noticed that the build failed due to a linting error > 110 | identifyEnv(_req: express.Request): string { . The function is basically a NOP and I prefix-ed the variable with an _ assuming that this would signal the linter that the variable is unused (after noticing the same pattern in some other part of the code). Do you have any recommendations for doing the same that is also acceptable to the linter ?

@GoWind GoWind force-pushed the abstract_env branch 2 times, most recently from 445c106 to e02d2a9 Compare August 4, 2020 14:24
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
@GoWind
Copy link
Contributor Author

GoWind commented Aug 4, 2020

@freben , I fixed the lint/type check errors and refactored the code. Can you take a look at the updated changes ?

Copy link
Member

@Rugvip Rugvip left a 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));
Copy link
Member

@Rugvip Rugvip Aug 4, 2020

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)));

Copy link
Member

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

Copy link
Contributor Author

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;
Copy link
Member

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 ??

Copy link
Contributor Author

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 ?

Copy link
Member

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 😁.

Copy link
Contributor Author

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.

Copy link
Member

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.

Copy link
Contributor Author

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 :)

Copy link
Member

@Rugvip Rugvip left a 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 ?? '',
Copy link
Member

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');
Copy link
Member

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.

Copy link
Contributor Author

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() ?? '';
Copy link
Member

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

Copy link
Contributor Author

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 ?? '';
Copy link
Member

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

Copy link
Contributor Author

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 '';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined here too

Copy link
Contributor Author

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
Copy link
Member

@Rugvip Rugvip left a 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}`;
Copy link
Member

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?

Copy link
Contributor Author

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.

@Rugvip
Copy link
Member

Rugvip commented Aug 6, 2020

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.

@GoWind
Copy link
Contributor Author

GoWind commented Aug 6, 2020

@Rugvip 👍 on checking with Google.

I'll look into getting some kind of basic changelog going so we can highlight some of these breaking changes.

Sounds great !
Meanwhile, I will try to use this opportunity to see if I can contribute documentation, perhaps a Documentation PR , explaining in little more detail how the state is encoded and the entire structure of the Authentication Providers and classes.

@Rugvip
Copy link
Member

Rugvip commented Aug 6, 2020

Changelog PR here: #1845, we can add an entry after this is merged though

Copy link
Member

@Rugvip Rugvip left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's :shipit:, followup doc PR would be awesome tho!

@Rugvip Rugvip merged commit 8bfba79 into backstage:master Aug 6, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants