Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,6 @@ typings/
.next

# heroku secrets
heroku_secrets.txt
heroku_secrets.txt

.claude/
41 changes: 41 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<path>` 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:<PORT>/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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
216 changes: 165 additions & 51 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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}!`));
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading