diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0c1b8b --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to `.env` and fill in values from your LaunchDarkly OAuth app. +# Register the app with redirect_uri http://localhost:4000/redirect (see README). + +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= + +LD_DOMAIN=https://app.launchdarkly.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index b5d947e..2a7c806 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,6 @@ typings/ .next # heroku secrets -heroku_secrets.txt \ No newline at end of file +heroku_secrets.txt + +.claude/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6cdbdc1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LaunchDarkly OAuth 2.0 starter project demonstrating the authorization code grant flow using the `client-oauth2` library. Single-file Express app that authenticates users via LaunchDarkly's OAuth provider and proxies GET requests to the LaunchDarkly API. + +## Commands + +- **Run**: `npm start` or `node app.js` (serves on port 4000 by default) +- **Install**: `npm install` +- **No test suite or linter configured.** Formatting follows Prettier (single quotes, trailing commas, 120 char width). + +## Architecture + +The entire server lives in `app.js` — a single Express application with these routes: + +- `GET /` — renders the Pug template with session state (token info, member name) +- `GET /auth` — initiates OAuth authorization code flow, redirects to LaunchDarkly +- `GET /redirect` — OAuth callback; exchanges code for token, fetches `/api/v2/members/me`, stores both in cookie-session +- `GET /refresh` — refreshes the OAuth token +- `GET /logout` — clears session +- `GET /get/:path*` — proxies any GET request to `LD_DOMAIN/api/v2/` using the stored OAuth token + +Session data (token + member info) is stored in an encrypted cookie via `cookie-session`, not a database. + +## Configuration + +All config is via environment variables loaded from `.env` by `dotenv`: + +| Variable | Required | Default | +|---|---|---| +| `OAUTH_CLIENT_ID` | Yes | — | +| `OAUTH_CLIENT_SECRET` | Yes | — | +| `LD_DOMAIN` | No | `https://app.launchdarkly.com` | +| `PORT` | No | `4000` | +| `REDIRECT_URI` | No | `http://localhost:/redirect` | +| `COOKIE_SESSION_SECRET` | No | hardcoded fallback | + +Register an OAuth app in LaunchDarkly with redirect URI `http://localhost:4000/redirect` to get the client ID and secret. diff --git a/README.md b/README.md index d227248..ec8cb46 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,6 @@ See [Authorizing OAuth Applications](https://docs.launchdarkly.com/docs/authoriz 3. Create a `.env` file with the following required environment variables (LaunchDarkly provides this information after registering your app): - `OAUTH_CLIENT_ID` (client_id) - `OAUTH_CLIENT_SECRET` (client_secret) -4. Run `node app.js` to start the express server. +4. Run `npm run start` to start the express server. 5. Visit http://localhost:4000 to begin the authorization process. 6. To confirm that the OAuth hand-shake was successful, click on some of the example endpoints listed on http://localhost:4000 and look for a successful response. diff --git a/app.js b/app.js index 9aa4637..05bc27c 100644 --- a/app.js +++ b/app.js @@ -7,6 +7,13 @@ const axios = require('axios'); const moment = require('moment'); const ClientOAuth2 = require('client-oauth2'); +// Decode a JWT payload without verifying the signature (for display purposes) +function decodeJwtPayload(token) { + const parts = token.split('.'); + if (parts.length !== 3) return null; + return JSON.parse(Buffer.from(parts[1], 'base64url').toString()); +} + // After registering your OAuth client you will be given the following credentials const CLIENT_ID = process.env.OAUTH_CLIENT_ID; const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET; @@ -54,21 +61,37 @@ app.get('/', (req, res) => { const displayName = name.length > 0 ? name : memberInfo.email; context.message = `Hello, ${displayName}`; } + + if (req.session.idTokenClaims) { + context.isOidc = true; + context.idTokenClaims = JSON.stringify(req.session.idTokenClaims, null, 2); + context.idTokenRaw = req.session.idTokenRaw; + context.oidcUserInfo = JSON.stringify(req.session.oidcUserInfo, null, 2); + } + res.render('index', context); }); -// Delete cookie-session data and go home +// Delete session data and go home app.get('/logout', (req, res) => { - delete req.session.oauthTokenData; + req.session = null; res.redirect('/'); }); -// Begin the OAuth 2.0 flow +// Begin the standard OAuth 2.0 flow app.get('/auth', function (req, res) { + req.session.useOidc = false; var uri = launchDarklyAuth.code.getUri(); res.redirect(uri); }); +// Begin the OIDC flow (adds openid scope, uses /oidc/token endpoint) +app.get('/auth/oidc', function (req, res) { + req.session.useOidc = true; + var uri = launchDarklyAuth.code.getUri({ scopes: ['writer', 'openid'] }); + res.redirect(uri); +}); + // Make any GET request to LaunchDarkly's API using URL parameters app.get('/get/:path*', function (req, res) { if (req.session.oauthTokenData === undefined) { @@ -94,62 +117,153 @@ app.get('/get/:path*', function (req, res) { }); app.get('/redirect', function (req, res) { - launchDarklyAuth.code - .getToken(req.originalUrl) - .then(function (token) { - console.log(token); //=> { accessToken: '...', tokenType: 'bearer', ... } - // The token should ideally be saved in the database at this point - req.session.oauthTokenData = { - access: token.accessToken, - refresh: token.refreshToken, - expires: token.expires, - }; - - // use the token to get the user's member information and save it in the session - const ldReq = token.sign({ - method: 'get', - url: `${LD_DOMAIN}/api/v2/members/me`, + if (req.session.useOidc) { + // OIDC flow: manually exchange the authorization code at the OIDC token endpoint + const url = new URL(req.originalUrl, `http://localhost:${PORT}`); + const code = url.searchParams.get('code'); + + const params = new URLSearchParams(); + params.append('grant_type', 'authorization_code'); + params.append('code', code); + params.append('redirect_uri', REDIRECT_URI); + params.append('client_id', CLIENT_ID); + params.append('client_secret', CLIENT_SECRET); + + axios + .post(`${LD_DOMAIN}/trust/oidc/token`, params) + .then(function (tokenResponse) { + const data = tokenResponse.data; + req.session.oauthTokenData = { + access: data.access_token, + refresh: data.refresh_token, + expires: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, + }; + + // Decode the ID token immediately (it expires in ~5 seconds) + if (data.id_token) { + req.session.idTokenRaw = data.id_token; + req.session.idTokenClaims = decodeJwtPayload(data.id_token); + } + + // Fetch userinfo and member info in parallel + return Promise.all([ + axios.get(`${LD_DOMAIN}/trust/oidc/userinfo`, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }), + axios.get(`${LD_DOMAIN}/api/v2/members/me`, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }), + ]); + }) + .then(function ([userInfoResponse, memberResponse]) { + req.session.oidcUserInfo = userInfoResponse.data; + const { firstName, lastName, role, email, customRoles } = memberResponse.data; + req.session.memberInfo = { firstName, lastName, role, email, customRoles }; + res.redirect('/'); + }) + .catch((e) => res.send(e.message)); + } else { + // Standard OAuth flow + launchDarklyAuth.code + .getToken(req.originalUrl) + .then(function (token) { + console.log(token); //=> { accessToken: '...', tokenType: 'bearer', ... } + req.session.oauthTokenData = { + access: token.accessToken, + refresh: token.refreshToken, + expires: token.expires, + }; + + const ldReq = token.sign({ + method: 'get', + url: `${LD_DOMAIN}/api/v2/members/me`, + }); + axios(ldReq) + .then((memberResponse) => { + const { firstName, lastName, role, email, customRoles } = memberResponse.data; + req.session.memberInfo = { firstName, lastName, role, email, customRoles }; + res.redirect('/'); + }) + .catch((e) => res.send(e.message)); + }) + .catch((e) => { + res.send(e.message); }); - axios(ldReq) - .then((memberResponse) => { - const { firstName, lastName, role, email, customRoles } = memberResponse.data; - req.session.memberInfo = { firstName, lastName, role, email, customRoles }; - res.redirect('/'); - }) - .catch((e) => res.send(e.message)); - }) - .catch((e) => { - res.send(e.message); - }); + } }); app.get('/refresh', function (req, res) { if (req.session.oauthTokenData === undefined) { res.redirect('/'); + return; } - const token = launchDarklyAuth.createToken( - req.session.oauthTokenData.access, - req.session.oauthTokenData.refresh, - 'bearer', - ); - token - .refresh() - .then((updatedToken) => { - console.log('Token successfully updated:', updatedToken !== token); //=> true - console.log('New OAuth Token:', updatedToken.accessToken); - - // The token should ideally be saved in the database at this point - // This example stores the token information in the cookie-session - req.session.oauthTokenData = { - access: updatedToken.accessToken, - refresh: updatedToken.refreshToken, - expires: updatedToken.expires, - }; - res.redirect('/'); + + if (req.session.useOidc) { + // OIDC refresh: manually POST to the OIDC token endpoint + const params = new URLSearchParams(); + params.append('grant_type', 'refresh_token'); + params.append('refresh_token', req.session.oauthTokenData.refresh); + params.append('client_id', CLIENT_ID); + params.append('client_secret', CLIENT_SECRET); + + axios + .post(`${LD_DOMAIN}/trust/oidc/token`, params) + .then(function (tokenResponse) { + const data = tokenResponse.data; + req.session.oauthTokenData = { + access: data.access_token, + refresh: data.refresh_token, + expires: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, + }; + if (data.id_token) { + req.session.idTokenRaw = data.id_token; + req.session.idTokenClaims = decodeJwtPayload(data.id_token); + } + res.redirect('/'); + }) + .catch((e) => res.send(e.message)); + } else { + // Standard OAuth refresh + const token = launchDarklyAuth.createToken( + req.session.oauthTokenData.access, + req.session.oauthTokenData.refresh, + 'bearer', + ); + token + .refresh() + .then((updatedToken) => { + console.log('Token successfully updated:', updatedToken !== token); //=> true + console.log('New OAuth Token:', updatedToken.accessToken); + req.session.oauthTokenData = { + access: updatedToken.accessToken, + refresh: updatedToken.refreshToken, + expires: updatedToken.expires, + }; + res.redirect('/'); + }) + .catch((e) => { + res.send(e.message); + }); + } +}); + +// Fetch the OAuth Authorization Server Metadata (RFC 8414) +app.get('/discovery', function (req, res) { + axios + .get(`${LD_DOMAIN}/.well-known/oauth-authorization-server`) + .then((response) => res.json(response.data)) + .catch((e) => res.status(500).send(e.message)); +}); + +// Fetch OIDC userinfo for the current session +app.get('/userinfo', function (req, res) { + if (!req.session.oauthTokenData) return res.redirect('/'); + axios + .get(`${LD_DOMAIN}/trust/oidc/userinfo`, { + headers: { Authorization: `Bearer ${req.session.oauthTokenData.access}` }, }) - .catch((e) => { - res.send(e.message); - }); + .then((response) => res.json(response.data)) + .catch((e) => res.status(500).send(e.message)); }); app.listen(PORT, () => console.log(`Example app listening on port ${PORT}!`)); diff --git a/package-lock.json b/package-lock.json index 5374b02..1e7d179 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "hello-oauth-node", "version": "1.0.0", "license": "MIT", "dependencies": { diff --git a/public/styles.css b/public/styles.css index 384a6df..a135396 100644 --- a/public/styles.css +++ b/public/styles.css @@ -27,3 +27,32 @@ ul { h1 { font-size: 28px; } + +pre.code-block { + background-color: #f4f4f4; + border: 1px solid #ddd; + border-radius: 4px; + padding: 12px; + overflow-x: auto; + max-height: 300px; + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-all; +} + +p.flow-indicator { + color: #666; + font-style: italic; + margin-bottom: 1rem; +} + +ul.oidc-notes { + list-style-type: disc; + padding-left: 1.5rem; +} + +ul.oidc-notes li { + margin-bottom: 0.5rem; +} diff --git a/views/index.pug b/views/index.pug index 2c723bf..972fe36 100644 --- a/views/index.pug +++ b/views/index.pug @@ -6,17 +6,50 @@ html div(class='container') h1= message if !loggedIn - h3 - a(href="/auth") Authorize this app + div + h3 Choose an authentication flow: + ul + li + a(href="/auth") Standard OAuth 2.0 + | — uses #[code /trust/oauth/token], requests "writer" scope + li + a(href="/auth/oidc") OIDC Flow + | — uses #[code /oidc/token], requests "openid writer" scopes, returns an ID token + div + h4 Server Discovery + p + a(href="/discovery") View OAuth Authorization Server Metadata + | (#[code /.well-known/oauth-authorization-server]) else + if isOidc + p.flow-indicator Authenticated via OIDC flow (openid scope) + else + p.flow-indicator Authenticated via standard OAuth 2.0 flow div h3= tokenMessage h4 This token expires #{expiresIn}. #[a(href="/refresh") Refresh it now] + if isOidc + div + h2 OIDC Information + h3 ID Token (JWT) + pre.code-block= idTokenRaw + h3 Decoded ID Token Claims + pre.code-block= idTokenClaims + h3 UserInfo Endpoint Response + pre.code-block= oidcUserInfo + p + a(href="/userinfo") Fetch /oidc/userinfo now + div + h4 Notes + ul.oidc-notes + li The ID token is a JWT signed with RS256 and has a very short expiry (~5 seconds). The claims above were decoded immediately upon receipt. + li The #[code sub] claim format is: #[code acct/{accountID}:member/{memberID}] + li The #[code /oidc/userinfo] endpoint returns user information in exchange for a valid access token. div - p - | You can test LaunchDarkly GET requests using this app by navigating to - | #[code /get/some-endpoint]. - p For example: + p + | You can test LaunchDarkly GET requests using this app by navigating to + | #[code /get/some-endpoint]. + p For example: ul li #[a(href="/get/members/me") /get/members/me] li #[a(href="/get/projects/default") /get/projects/default] @@ -24,6 +57,9 @@ html li #[a(href="/get/flags/default") /get/flags/default] div p To test with cURL, use: - pre= `curl -X GET ${apiUrl}/projects -H 'Authorization: Bearer ${token}'` + pre.code-block= `curl -X GET ${apiUrl}/projects -H 'Authorization: Bearer ${token}'` + div + h4 Server Discovery + a(href="/discovery") View /.well-known/oauth-authorization-server metadata div - a(href="/logout") log out \ No newline at end of file + a(href="/logout") log out