Skip to content

Commit 83ab388

Browse files
committed
Initial commit
0 parents  commit 83ab388

9 files changed

+3153
-0
lines changed

.env.sample

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
SECRET=some_long_secret_to encrypt_the_session_cookie
2+
ISSUER_BASE_URL=https://issuer.example.com
3+
BASE_URL=http://localhost:3000
4+
CLIENT_ID=your_client_id
5+
CLIENT_SECRET=your_client_secret
6+
ALLOWED_AUDIENCES=your_audience
7+
SCOPE=read:something

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
.env

README.md

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# POC for OAuth TMI BFF
2+
3+
This repository contains a POC for the `Token Mediating and session Information Backend For Frontend` draft [https://github.com/b---c/oauth-token-bff](https://github.com/b---c/oauth-token-bff)
4+
5+
- A web app that does OIDC login using `express-openid-connect`
6+
- An API protected with `express-oauth2-bearer`
7+
8+
The Web app also exposes the `/.well-known/bff-*` endpoints that are used by an authenticated frontend to access an API directly.
9+
10+
```shell
11+
$ npm install
12+
$ npm start
13+
App listening on http://localhost:3000
14+
API listening on http://localhost:3001
15+
```

api.js

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
require('dotenv').config();
2+
const express = require('express');
3+
const cors = require('cors');
4+
const { auth, requiredScopes } = require('express-oauth2-bearer');
5+
6+
const api = express();
7+
8+
api.use(
9+
cors({
10+
origin: process.env.BASE_URL,
11+
allowedHeaders: ['Authorization'],
12+
})
13+
);
14+
15+
api.use(auth());
16+
17+
api.get('/api', requiredScopes(process.env.SCOPE), (req, res) => {
18+
res.json({ msg: 'Hello World!' });
19+
});
20+
21+
module.exports = api;

app.js

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
require('dotenv').config();
2+
const express = require('express');
3+
const { auth } = require('express-openid-connect');
4+
5+
const app = express();
6+
const audience = process.env.ALLOWED_AUDIENCES;
7+
const scopes = `openid profile email ${process.env.SCOPE} offline_access`;
8+
9+
app.set('views', __dirname);
10+
app.set('view engine', 'ejs');
11+
12+
const requiresAuth = (req, res, next) => {
13+
if (req.oidc?.isAuthenticated()) {
14+
next();
15+
} else {
16+
next(new ATError('invalid_session', 'The user is not logged in'));
17+
}
18+
};
19+
20+
app.use(
21+
auth({
22+
authRequired: false,
23+
authorizationParams: {
24+
response_type: 'code',
25+
audience,
26+
scope: scopes,
27+
},
28+
})
29+
);
30+
31+
app.get('/', (req, res, next) => {
32+
res.render('frontend.ejs', {
33+
loggedIn: req.oidc.isAuthenticated(),
34+
});
35+
next();
36+
});
37+
38+
/**
39+
* OAuth TMI BFF bit...
40+
*/
41+
42+
class ATError extends Error {
43+
status = 400;
44+
statusCode = 400;
45+
46+
constructor(error, description) {
47+
super(description || error);
48+
this.error = error;
49+
this.error_description = description;
50+
}
51+
}
52+
53+
app.get('/.well-known/bff-sessioninfo', requiresAuth, (req, res, next) => {
54+
const user = req.oidc.user;
55+
res.json(user);
56+
next();
57+
});
58+
59+
app.get('/.well-known/bff-token', requiresAuth, async (req, res, next) => {
60+
try {
61+
const { resource, scope: expected } = req.query;
62+
let {
63+
access_token,
64+
expires_in,
65+
isExpired,
66+
refresh,
67+
// FIXME: express-openid-connect does not expose the 'scope'
68+
scope = scopes,
69+
} = req.oidc.accessToken;
70+
71+
if (!access_token) {
72+
throw new ATError('backend_not_ready', 'Missing Access Token');
73+
}
74+
if (resource !== undefined && resource !== audience) {
75+
throw new ATError('backend_not_ready', 'Resource mismatch');
76+
}
77+
if (scope !== undefined && expected !== undefined) {
78+
const actual = new Set(scope.split());
79+
if (!expected.every(Set.prototype.has.bind(actual))) {
80+
throw new ATError('backend_not_ready', 'Scope mismatch');
81+
}
82+
}
83+
if (isExpired()) {
84+
if (!req.oidc.refreshToken) {
85+
throw new ATError('backend_not_ready', 'Access Token expired');
86+
}
87+
({ access_token, expires_in } = await refresh());
88+
}
89+
90+
res.json({ access_token, expires_in, scope });
91+
next();
92+
} catch (e) {
93+
next(e);
94+
}
95+
});
96+
97+
/**
98+
* End OAuth TMI BFF bit
99+
*/
100+
101+
app.use((err, req, res, next) => {
102+
res
103+
.status(err.status || 500)
104+
.json({ error: err.error, error_description: err.error_description });
105+
});
106+
107+
module.exports = app;

frontend.ejs

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<style>
6+
* { box-sizing: border-box; }
7+
body { width: 50%; margin: 1rem auto; font-family: sans-serif; }
8+
#result { border: 1px solid #ccc; padding: 10px; font-family: monospace; }
9+
@keyframes yellowfade { from { background: #ffa; } to { background: transparent; } }
10+
#result pre { animation: yellowfade 1s; padding: 5px; margin: 0; overflow: hidden; }
11+
</style>
12+
</head>
13+
<body>
14+
<div class="wrapper">
15+
<% if (!loggedIn) { %>
16+
<button onclick="location.href = '/login'">login</button>
17+
<% } else { %>
18+
<button onclick="location.href = '/logout'">logout</button>
19+
<button onclick="request(3000, '/.well-known/bff-sessioninfo')">Get User</button>
20+
<button onclick="request(3001, '/api', true)">Request API</button>
21+
<button onclick="request(3001, '/api', true, 'foobar')">Request API (wrong audience)</button>
22+
<% } %>
23+
<h3>Log:</h3>
24+
<div id="result"></div>
25+
</div>
26+
<script></script>
27+
<script>
28+
const result = document.querySelector('#result');
29+
30+
const log = (msg) => {
31+
const item = document.createElement('pre');
32+
item.innerText = `${new Date().toLocaleTimeString()} ${msg}`;
33+
result.prepend(item);
34+
};
35+
36+
let accessToken;
37+
let expiresAt;
38+
39+
const getAccessToken = async (resource) => {
40+
if (!resource && accessToken && (expiresAt > Date.now())) {
41+
return accessToken;
42+
}
43+
const { access_token, expires_in } = await request(3000, `/.well-known/bff-token${resource && '?resource=' + resource || ''}`);
44+
accessToken = access_token;
45+
expiresAt = Date.now() + (expires_in * 1000);
46+
return accessToken;
47+
}
48+
49+
const request = async (port, path, requiresAt, resource) => {
50+
const response = await fetch(`http://localhost:${port}${path}`, requiresAt && {
51+
headers: {
52+
authorization: `Bearer ${await getAccessToken(resource)}`,
53+
},
54+
});
55+
const json = await response.json();
56+
log(`${response.status} ${path} ${JSON.stringify(json, null, 2)}`);
57+
return json;
58+
};
59+
</script>
60+
</body>
61+
</html>

index.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const app = require('./app');
2+
const api = require('./api');
3+
4+
app.listen(3000, () => console.log('App listening on http://localhost:3000'));
5+
api.listen(3001, () => console.log('API listening on http://localhost:3001'));

0 commit comments

Comments
 (0)