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

Support Single Sign-on with OpenID Connect #910

Merged
merged 4 commits into from
Sep 12, 2023

Conversation

alxndrsn
Copy link
Contributor

@alxndrsn alxndrsn commented May 31, 2023

Backend portion of getodk/central#449. Requires frontend changes at getodk/central-frontend#819

sadiqkhoja

This comment was marked as outdated.

Copy link
Contributor

@sadiqkhoja sadiqkhoja left a comment

Choose a reason for hiding this comment

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

Should we write isolated integration tests for new endpoints defined in lib/resources/oidc.js? That would assert

  • Audit logs are created
  • Response body and headers are correct

It would be nice to write unit tests for lib/util/oidc.js

lib/resources/users.js Outdated Show resolved Hide resolved
lib/util/sessions.js Outdated Show resolved Hide resolved
Comment on lines 154 to 152

function clientError(res, message) {
log(res.path, 'RETURNING ERROR 400', message);
// FIXME stop returning error details to client?
return respond(res, 400, page(html`
<h1>Error!</h1>
<div><pre>${message}</pre></div>
<div><a href="/">Go home</a></div>
`));
}

const htmlContent = contentType('text/html');

function respond(res, status, body, header) {
if(arguments.length === 2) {
body = status;
status = 200;
}
res.status(status);
return htmlContent(body);
}

// handy dev function for enabling syntax hilighting of html
function html([ first, ...rest ], ...vars) {
return first + vars.map((v, idx) => [ v, rest[idx] ]).flat().join('');
}

function page(head, body) {
if(arguments.length === 1) {
body = head;
head = '';
}

// Style to look like odk-central-frontend
return html`
<html>
<head>
${head}
<style>
body { margin:0; font-family:"Helvetica Neue", Helvetica, Arial, sans-serif; background-color:#f7f7f7; }
.header { background-color:#bd006b; color:white; box-shadow: 0 3px 0 #dedede; border-top: 3px solid #8d0050; padding:0.5em 0; }
.header a,.header a:active,.header a:visited { margin:1em; font-size:12px; font-weight:700; color:white; text-decoration:none; }
#content { margin:3em auto; width:80%; background-color:white; border-color:rgb(51, 51, 51); box-shadow:rgba(0, 0, 0, 0.25) 0px 0px 24px 0px, rgba(0, 0, 0, 0.28) 0px 35px 115px 0px; }
#content h1 { background-color:#bd006b; color:white; border-bottom:1px solid #ddd; padding:10px 15px; font-size:18px; margin:0; }
#content div { border-bottom:1px solid #ddd; padding:10px 15px; }
#content div:last-child { border-bottom:none; background-color:#eee; }
#content div:last-child a { background-color:#009ecc; color:white; display:inline-block; padding:6px 10px 5px; border-radius:2px; text-decoration:none; font-size:12px; border-color:#286090; }
#content div:last-child a:hover { background-color:#0086ad; border-color:#204d74; }
</style>
</head>
<body>
<div class="header"><a href="/">ODK Central</a></div>
<div id="content">
${body}
</div>
</body>
</html>
`;
}

function userNotFound(res, email) {
return clientError(res, `Authentication successful, but there is no user in the system with the supplied email address (${email}).`); // TODO reject unauthorized? or don`t leak info? etc.
}

function emailNotVerified(res, userinfo) {
// TODO what should this do? redirect to login page with a toast informing the user something?

const { name, email } = userinfo;

return respond(res, 403, page(html`
<h1>Hello, ${name} (${email})!</h1>
<h2>Your email is not verified.</h2>
<h2>TODO</h2>
<div><pre>
* delete session and allow user to retry
</pre></div>
<div><a href="/">Go home</a></div>
`));
}

function debugIdpResponse(res, session, userinfo, user) {
const { name, email, picture } = userinfo;

return respond(res, page(html`
<h1>Hello, ${name} (${email})!</h1>
<div><h3>Profile picture</h3><img src="${picture}" style="min-width:200px; min-height:200px; border:solid black 1px"/></div>
<div><h3>User </h3><code><pre>${JSON.stringify(user, null, 2)}</pre></code></div>
<div><h3>Session </h3><code><pre>${JSON.stringify(session, null, 2)}</pre></code></div>
<div><h3>User Info</h3><code><pre>${JSON.stringify(userinfo, null, 2)}</pre></code></div>
<div>
<h2>TODO</h2>
<pre>
* stop displaying error details to client
* hide set-password UI
* don't request password from admin when creating new user account
</pre>
</div>
<div><a href="/">Continue to central</a></div>
`));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

What if we move this to frontend repository and just redirect from here?

I don't like

  1. HTML literals in JS files
  2. Duplication of html and css

Copy link
Member

Choose a reason for hiding this comment

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

Frontend would also provide i18n for any text.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍 This should be possible for errors if specific codes are defined and then a corresponding view is implemented in frontend.

For the success path, I think the extra backend step is required to maintain the current session/csrf cookie settings.

Copy link
Member

Choose a reason for hiding this comment

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

For the success path, I think the extra backend step is required to maintain the current session/csrf cookie settings.

Could you say more about why this is required? If this requirement is due to the setup in Frontend, maybe we could make changes there. Would it help to make the Frontend request to /v1/oidc/login async?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Session ID and CSRF cookies are both SameSite=Strict:

response.cookie('__Host-session', session.token, { path: '/', expires: session.expiresAt,
httpOnly: true, secure: true, sameSite: 'strict' });
response.cookie('__csrf', session.csrf, { expires: session.expiresAt,
secure: true, sameSite: 'strict' });

This means that if backend redirects to frontend at
// This redirect is neat, but breaks SameSite: Secure cookies.
//return redirect(302, '/'); // REVIEW: internally, the redirect() function throws... which is odd
// Instead, we need to render a page and then "browse" from that page to the normal frontend:
// TODO id=cl only set for playwright.. why can't it locate this anchor in any other way?
// TODO this approach does not work. Either:
// 1. expose a browse-to-next page on the frontend, and pass `next` value there, or
// 2. re-implement
const nextPath = safeNextPathFrom(next);
return respond(res, page(
html`<meta http-equiv="refresh" content="0; url=${nextPath}">`,
html`
<h1>Authentication Successful</h1>
<div><a href="${nextPath}" id="cl">Continue to ODK Central</a></div>
`,
));
, the browser does not send cookies, so the user cannot be authorised.

There are tests for this at:

With a 302 redirect, the tests fail like this:

3) [firefox] › happy-next.spec.js:21:1 › can log in ──────────────────────────────────────────────

  AssertionError: No session cookie found!

     at utils.js:55

    53 |   console.log('requestCookies:', JSON.stringify(requestCookies, null, 2));
    54 |
  > 55 |   assert(requestCookies[SESSION_COOKIE], 'No session cookie found!');
       |   ^   
    56 |   assert(requestCookies['__csrf'],       'No CSRF cookie found!');
    57 |   assert.equal(Object.keys(requestCookies).length, 2, 'Unexpected requestCookie count!');
    58 | } 

      at assertLoginSuccessful (/odk-central-backend/oidc-tester/playwright-tests/src/utils.js:55:3)
      at /odk-central-backend/oidc-tester/playwright-tests/src/happy-next.spec.js:25:3

See e.g. https://github.com/alxndrsn/odk-central-backend/actions/runs/5666083718/job/15352099923#step:5:6265

Copy link
Member

Choose a reason for hiding this comment

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

OK, that's super helpful, thank you! I think I'm understanding things better now. Just to confirm:

  1. An error results in a redirect to Frontend. The redirect specifies an error code as a query parameter, which Frontend uses to show an alert.
  2. A success renders an HTML page. The page does a client-side redirect to Frontend and also links to Frontend.

That all sounds good to me. 👍 For the success page, what do you think about leaving that as unstyled as possible and not trying to make it look like Frontend? Since the user will only be on the page for a short time and is automatically redirected, I don't feel like it needs to be styled. I've seen that pattern elsewhere and feel like it wouldn't be overly confusing to users.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's a parallel discussion at https://github.com/getodk/central-backend/pull/910/files#r1310995967:

I've also seen the pattern of unstyled forwarding pages elsewhere and I'm not a fan! I think a branded forwarding page maintains users' trust that they are inside a legitimate workflow.

Copy link
Member

Choose a reason for hiding this comment

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

I've filed a related issue: #971

lib/util/oidc.js Outdated Show resolved Hide resolved
config/oidc-development.json Outdated Show resolved Hide resolved
@matthew-white

This comment was marked as outdated.

lib/resources/oidc.js Outdated Show resolved Hide resolved
@matthew-white

This comment was marked as resolved.

@matthew-white matthew-white mentioned this pull request Aug 11, 2023
2 tasks
@sadiqkhoja

This comment was marked as resolved.

@matthew-white

This comment was marked as resolved.

@alxndrsn alxndrsn force-pushed the sso-node-openid-client branch 2 times, most recently from cc4b222 to 30e5bf1 Compare August 28, 2023 10:52
Makefile Outdated Show resolved Hide resolved
@alxndrsn alxndrsn requested a review from sadiqkhoja August 28, 2023 11:29
@matthew-white
Copy link
Member

matthew-white commented Aug 29, 2023

This may be addressed by other changes you're working on, but I'm noticing that when I log in on dev, a request is sent to /csp-report. The violated-directive is style-src-elem.

lib/formats/mail.js Outdated Show resolved Hide resolved
oidc-tester/.gitignore Outdated Show resolved Hide resolved
Copy link
Contributor

@sadiqkhoja sadiqkhoja left a comment

Choose a reason for hiding this comment

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

There are bunch of TODOs, to me all of them are about explanation of the code, I recommend that we merge this branch and update comments in a separate PR

// TODO add some thoughts about why __Host is important in prod but impossible in dev
const SESSION_COOKIE = HTTPS_ENABLED ? '__Host-session' : 'session';

function createUserSession({ Audits, Sessions }, headers, user) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This doesn't look like a utility function. Where this should reside? maybe under lib/http folder or a new folder lib/resources/shared?

And we can make this arrow function

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This doesn't look like a utility function. Where this should reside?

The current location was my best guess. What qualifies something as a utility function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This has been converted to an anonymous function 👍

Copy link
Member

Choose a reason for hiding this comment

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

I agree that it's surprising to see createUserSession() here, since it contains specific logic related to endpoints/resources/responses. Usually such logic is located in lib/resources. What makes this a particularly interesting case is that this logic is shared across multiple resource files (sessions.js and oidc.js). It's hard to think of a similar case, which is why I'm guessing @sadiqkhoja suggested lib/resources/shared.

That said, I can see why it's nice for createUserSession() to be in the same file as SESSION_COOKIE and HTTPS_ENABLED. SESSION_COOKIE is also used in lib/http/preprocessors.js. Since that's in a different directory from lib/resources, that might be a strike against moving this code to inside lib/resources.

lib/http seems like a reasonable location to me. That directory contains endpoint/resource/response logic that's shared across endpoints (in some cases, logic that's shared across all endpoints, and in other cases, logic that's shared across a smaller subset of endpoints). I also understand though that it's sometimes hard to find the exact right place for a function and that sometimes these sorts of distinctions can get blurry or arbitrary. @alxndrsn, I think you should make a decision one way or another for now, then we can continue thinking in general about what goes where.

lib/util/html.js Show resolved Hide resolved
lib/util/html.js Show resolved Hide resolved
lib/util/html.js Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

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

arrow functions would be nice here

lib/http/service.js Outdated Show resolved Hide resolved
lib/resources/oidc.js Show resolved Hide resolved
lib/resources/oidc.js Show resolved Hide resolved
lib/resources/oidc.js Outdated Show resolved Hide resolved
lib/resources/oidc.js Outdated Show resolved Hide resolved
Copy link
Contributor

@sadiqkhoja sadiqkhoja left a comment

Choose a reason for hiding this comment

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

added few minor comments about the tests. I am happy with this PR 🎉 can't wait to get this merged

}
};

async function oidcAuthFor(service, user) {
Copy link
Contributor

Choose a reason for hiding this comment

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

arrow function would be nice here

const makeFetchCookie = require('fetch-cookie');

module.exports = async (service, user, includeCsrf) => {
if (!user) throw new Error('Did you forget the **service** arg?');
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't get this, if user is not there then we are saying service is missing?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When refactoring tests, I found it easy to mistakenly write authenticateUser('alice') instead of authenticateUser(service, 'alice'). This check was very helpful in catching those mistakes - without the check here, the eventual error was nebulous. I think it will be helpful for future test writers to keep this check here. But this branch is passing now, so I can delete it if it's not helpful.

Alternatively, maybe the function could be refactored to be an instance method of service. Would that be preferred?

Copy link
Contributor

Choose a reason for hiding this comment

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

the function could be refactored to be an instance method of service

I like the idea

test/integration/api/users.js Outdated Show resolved Hide resolved
test/integration/api/users.js Outdated Show resolved Hide resolved
oidc-dev/scripts/wait-for-it.sh Outdated Show resolved Hide resolved
Comment on lines 134 to 138
if (oidc.isEnabled()) {
// Don't allow modifying email or password for users when using OIDC.
// This can't be done in /frames/ as it does not allow conditional filtering.
const { email, password, ...filtered } = body;
return filtered;
Copy link
Member

Choose a reason for hiding this comment

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

The release criteria suggest that admins still need to be able to update users' email addresses. @sadiqkhoja left a comment about that in the release criteria, but if we do decide to run with that behavior, this logic would need to change.

Also, password isn't a writable field on the frame, so I don't think it needs to be filtered out here: fromApi() should always filter it out. This endpoint can't be used to change a password: that's a different endpoint.

Copy link
Contributor Author

@alxndrsn alxndrsn Sep 2, 2023

Choose a reason for hiding this comment

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

So can this whole block be deleted? (i.e. revert all changes to this route)

Copy link
Member

@matthew-white matthew-white Sep 2, 2023

Choose a reason for hiding this comment

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

I think there does need to be something here. If SSO is enabled, we don't want non-admin users to use this endpoint to update their own email. Note that in auth, users can user.update themselves even if they can't user.update other users:

if (actor.acteeId === acteeId) {
// special privileges actors always get on themselves.
if ((verb === 'user.read') || (verb === 'user.update'))
return resolve(true);

Non-admin users should still be able to use this endpoint to update their own display name.

I think this means that we should filter out email if the user can't user.update users in general. I think you can check for that by calling auth.can('user.update', User.species), which returns a promise.

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've added a test and updated the existing tests for this case.

With SSO enabled, should an admin be able to change their own email?

Copy link
Member

Choose a reason for hiding this comment

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

I personally have no preference. @issa-tseng maybe you have strong feelings?

I think we could say admins can change their own email because they should have a lot more context on the implications. This is also more helpful in contexts in which there’s only one site wide admin.

It would also be very defensible to say admins need to use the command line or talk to another admin to get their email address changed.

Whatever it is, it should be in the docs.

Copy link
Member

Choose a reason for hiding this comment

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

Whatever the behavior is, we should also make sure that Frontend matches it. Right now, Frontend will allow an admin to change their own email.

Copy link
Member

Choose a reason for hiding this comment

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

I think the way it works now makes a lot of sense! Let's keep it and document it.

@alxndrsn

This comment was marked as resolved.

@alxndrsn alxndrsn marked this pull request as ready for review September 8, 2023 14:37
@alxndrsn alxndrsn force-pushed the sso-node-openid-client branch from c1ef664 to c9099c0 Compare September 8, 2023 14:42
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.

4 participants