-
Notifications
You must be signed in to change notification settings - Fork 76
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
Conversation
9386a7b
to
00427dc
Compare
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.
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/oidc.js
Outdated
|
||
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> | ||
`)); | ||
} |
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.
What if we move this to frontend repository and just redirect from here?
I don't like
- HTML literals in JS files
- Duplication of html and css
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.
Frontend would also provide i18n for any text.
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 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.
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 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?
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.
Session ID and CSRF cookies are both SameSite=Strict
:
central-backend/lib/resources/sessions.js
Lines 39 to 42 in facf339
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
central-backend/lib/resources/oidc.js
Lines 130 to 146 in e7da23a
// 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> | |
`, | |
)); |
There are tests for this at:
- https://github.com/getodk/central-backend/blob/e7da23a3d3ac248f19a179e59a432c756f7c6361/oidc-tester/playwright-tests/src/happy.spec.js
- https://github.com/getodk/central-backend/blob/e7da23a3d3ac248f19a179e59a432c756f7c6361/oidc-tester/playwright-tests/src/happy-next.spec.js
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
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.
OK, that's super helpful, thank you! I think I'm understanding things better now. Just to confirm:
- 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.
- 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.
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.
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.
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've filed a related issue: #971
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
cc4b222
to
30e5bf1
Compare
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 |
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.
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
lib/util/sessions.js
Outdated
// 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) { |
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 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
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 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?
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 has been converted to an anonymous function 👍
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 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.
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.
arrow functions would be nice here
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.
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) { |
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.
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?'); |
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 don't get this, if user
is not there then we are saying service
is missing?
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.
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?
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.
the function could be refactored to be an instance method of service
I like the idea
oidc-dev/playwright-tests/src/err-auth-ok-user-not-found.spec.js
Outdated
Show resolved
Hide resolved
lib/resources/users.js
Outdated
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; |
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.
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.
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 can this whole block be deleted? (i.e. revert all changes to this route)
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 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:
central-backend/lib/model/query/auth.js
Lines 26 to 29 in cedadf2
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.
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'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?
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 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.
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.
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.
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 think the way it works now makes a lot of sense! Let's keep it and document it.
This comment was marked as resolved.
This comment was marked as resolved.
c1ef664
to
c9099c0
Compare
Backend portion of getodk/central#449. Requires frontend changes at getodk/central-frontend#819