PageRenderTime 40ms CodeModel.GetById 20ms RepoModel.GetById 0ms app.codeStats 0ms

/content/posts/2022-04-11-architect-oidc-login.md

https://gitlab.com/jamietanna/jvt.me
Markdown | 179 lines | 138 code | 41 blank | 0 comment | 0 complexity | 84e175532156c8565be59526acd2d9c1 MD5 | raw file
  1. ---
  2. title: "Protecting an Architect Framework Application with OAuth2 or OpenID Connect\
  3. \ Authentication"
  4. description: "How to set up OAuth2/OpenID Connect authentication with an Architect\
  5. \ Framework application."
  6. date: "2022-04-11T11:10:29+0100"
  7. syndication:
  8. - "https://twitter.com/JamieTanna/status/1513461209563115524"
  9. tags:
  10. - "blogumentation"
  11. - "architect-framework"
  12. - "nodejs"
  13. - "aws-lambda"
  14. - "oidc"
  15. - "indieauth"
  16. - "oauth2"
  17. license_code: "Apache-2.0"
  18. license_prose: "CC-BY-NC-SA-4.0"
  19. image: "https://media.jvt.me/4bea95efe8.png"
  20. slug: "architect-oidc-login"
  21. ---
  22. I have a number of services that I use the [Architect Framework](https://arc.codes), as it's _really_ handy for creating an event-based, multi-Lambda (HTTP) application.
  23. One of the things I like to do to is secure my services behind OAuth2/OpenID Connect, as it's a standard way of handling authorization, and a "log in with OAuth2/OpenID Connect" is a well-supported operation across languages and technologies.
  24. For these Architect-backed services, I wanted to have a few protected routes such as access to logs that may expose sensitive information about the calls to the services.
  25. As I use [IndieAuth](https://indieauth.spec.indieweb.org) as my identity layer, and we've recently made a lot of efforts to align IndieAuth with OAuth2, it's actually very straightforward to integrate with a standard OAuth2 client.
  26. This leads to a slightly different flow than you'd use with a single service i.e. Google or GitHub login, but the gist of it should be viable for your own use.
  27. I have taken the below code from a sample project [on GitLab](https://gitlab.com/jamietanna/architect-fraemwork-openid-connect-example), as a standalone way to test this.
  28. I'm planning on publishing this as an NPM package so it's easier to use in a generic way, across projects.
  29. # Code
  30. ## Protecting resources
  31. Architect provides a really nicely abstracted session management setup, which allows us to set key-value data in the `req.session`, and means we don't need to handle how i.e. encrypt the session data, or manage session IDs.
  32. To give us a handy way to require authentication, we can take advantage of that and store information in our session about the logged-in user, as well as add an expiry to enforce re-authentication regularly.
  33. We can utilise the session handling in Architect to do something like this in our handler method for the incoming request:
  34. ```js
  35. async function handler(req) {
  36. const session = req.session
  37. const isLoggedIn = await auth.isLoggedIn(session);
  38. if (!isLoggedIn) {
  39. delete session.auth_me; // `me` is the subject of the request, i.e. https://www.jvt.me/
  40. delete session.auth_time;
  41. return {
  42. session,
  43. html: `You're not logged in`
  44. }
  45. }
  46. return {
  47. session,
  48. html: `Welcome back ${session.auth_me}`
  49. }
  50. }
  51. ```
  52. This utilises the following snippet from `auth.js` to validate whether the user is logged in:
  53. ```js
  54. const AUTH_TIME = 1000 * 60 * 60;
  55. function authHasExpired(auth_time, now) {
  56. return (now - auth_time) >= AUTH_TIME;
  57. }
  58. async function isLoggedIn(session) {
  59. if (session === undefined || !session) {
  60. return false
  61. }
  62. if (session.auth_me === undefined || session.auth_time === undefined) {
  63. return false
  64. }
  65. if (authHasExpired(session.auth_time, new Date().getTime())) {
  66. return false;
  67. }
  68. return true;
  69. }
  70. ```
  71. Note that as well as an authentication check, we could perform additional authorization checks to validate that the user is the right one i.e. checking that they are present in a list of users that are allowed access to specific pages.
  72. ## Setting up the authentication flow
  73. To actually start the authentication flow, we need a page to trigger this. In my case, I've got a `/start?me=${profile_url}` endpoint that uses IndieAuth to discover OAuth2 endpoints and then go through the OAuth2 flow as such, but you could just as easily have a `/start/github` that redirects to the GitHub authorization URL.
  74. ```js
  75. const arc = require('@architect/functions')
  76. const auth = require('@architect/shared/auth')
  77. const profile = require('@architect/shared/profile')
  78. const oidc = require('openid-client')
  79. async function handler(req) {
  80. // simplified for this example
  81. const session = req.session
  82. session.discovery = 'https://indieauth.jvt.me/.well-known/oauth-authorization-server';
  83. const issuer = await oidc.Issuer.discover(session.discovery);
  84. const client = await auth.createClient(issuer)
  85. const code_verifier = oidc.generators.codeVerifier();
  86. session.verifier = code_verifier;
  87. const code_challenge = oidc.generators.codeChallenge(code_verifier);
  88. const authorizationUrl = client.authorizationUrl({
  89. scope: 'profile',
  90. code_challenge,
  91. code_challenge_method: 'S256',
  92. });
  93. return {
  94. status: 302,
  95. session,
  96. headers: {
  97. location: authorizationUrl
  98. }
  99. }
  100. }
  101. ```
  102. Then, we have our callback URL:
  103. ```js
  104. const arc = require('@architect/functions')
  105. const auth = require('@architect/shared/auth')
  106. const oidc = require('openid-client')
  107. async function handler(req) {
  108. const session = req.session
  109. const issuer = await oidc.Issuer.discover(session.discovery);
  110. const client = await auth.createClient(issuer)
  111. const code_verifier = session.verifier;
  112. const params = req.queryStringParameters
  113. const redirect = await auth.redirectUri()
  114. const tokenSet = await client.oauthCallback(redirect, params, { code_verifier });
  115. session.auth_time = new Date().getTime()
  116. session.auth_me = tokenSet.me;
  117. // just an example, but we'd probably want to redirect to where we were pre-auth, using something from the `req.session`
  118. return {
  119. session,
  120. html: `<pre>${JSON.stringify(tokenSet)}</pre>`
  121. }
  122. }
  123. ```
  124. Note that I'm using the `me`, which is returned by the IndieAuth token endpoint. Ideally we would use the Token Introspection endpoint - which may return some user information in the claims - or for an OpenID Connect solution, I would use the userinfo endpoint.
  125. Notice that this takes advantage of a shared `createClient()` method, which simplifies duplication:
  126. ```js
  127. async function redirectUri() {
  128. return process.env.BASE_URL + 'callback'
  129. }
  130. async function createClient(issuer) {
  131. return new issuer.Client({
  132. client_id: process.env.BASE_URL,
  133. redirect_uris: [await redirectUri()],
  134. response_types: ['code'],
  135. token_endpoint_auth_method: 'none' // required for IndieAuth
  136. });
  137. }
  138. ```