Initial proof of concept
This commit is contained in:
commit
e3bb114f18
8 changed files with 2016 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
91
client.ts
Normal file
91
client.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import express from "express";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
import * as oid from "openid-client";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import { uid } from "uid";
|
||||||
|
|
||||||
|
dotenv.config({ quiet: true });
|
||||||
|
|
||||||
|
const AUTH_CLIENT_PORT = Number(process.env.AUTH_CLIENT_PORT) || 3002;
|
||||||
|
const CLIENT_URL = String(process.env.CLIENT_URL) || "http://localhost";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
const database: Record<
|
||||||
|
string,
|
||||||
|
{ oidConfig: oid.Configuration; pkceCodeVerifier: string }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
app.get("/login", async (req, res) => {
|
||||||
|
if (typeof req.query.provider != "string") {
|
||||||
|
res.status(400);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cbid = uid();
|
||||||
|
const redirect_uri = `${CLIENT_URL}/callback/${cbid}`;
|
||||||
|
const provider = new URL(req.query.provider);
|
||||||
|
const oidConfig = await oid.dynamicClientRegistration(
|
||||||
|
provider,
|
||||||
|
{
|
||||||
|
redirect_uris: [redirect_uri],
|
||||||
|
grant_types: ["authorization_code", "refresh_token"],
|
||||||
|
response_types: ["code"],
|
||||||
|
token_endpoint_auth_method: "client_secret_basic",
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ execute: [oid.allowInsecureRequests] },
|
||||||
|
);
|
||||||
|
const pkceCodeVerifier = oid.randomPKCECodeVerifier();
|
||||||
|
const code_challenge = await oid.calculatePKCECodeChallenge(pkceCodeVerifier);
|
||||||
|
database[cbid] = { oidConfig, pkceCodeVerifier };
|
||||||
|
setTimeout(() => {
|
||||||
|
delete database[cbid];
|
||||||
|
}, 600000 /* 10 minutes */);
|
||||||
|
res.redirect(
|
||||||
|
oid.buildAuthorizationUrl(oidConfig, {
|
||||||
|
redirect_uri,
|
||||||
|
scope: "openid",
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
}).href,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/callback/:cbid", async (req, res) => {
|
||||||
|
const cbid = req.params.cbid as string;
|
||||||
|
if (!(cbid in database)) {
|
||||||
|
res.status(400);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { oidConfig, pkceCodeVerifier } = database[cbid]!;
|
||||||
|
const fullUrl = `${req.protocol}://${req.get("host")}${req.url}`;
|
||||||
|
const url = new URL(fullUrl);
|
||||||
|
let tokens = await oid
|
||||||
|
.authorizationCodeGrant(oidConfig, url, {
|
||||||
|
pkceCodeVerifier,
|
||||||
|
idTokenExpected: true,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
res.cookie("auth_tokens", JSON.stringify(tokens)); // TODO this expiry should be same as token expiry
|
||||||
|
res.cookie("auth_claims", JSON.stringify(tokens!.claims())); // TODO this expiry should be same as token expiry
|
||||||
|
res.redirect("/session");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
app.get("/session", (req, res) => {
|
||||||
|
res.send(req.cookies.auth_claims);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(AUTH_CLIENT_PORT, (err) => {
|
||||||
|
if (!err) {
|
||||||
|
console.log(`Auth client listening on port ${AUTH_CLIENT_PORT}`);
|
||||||
|
} else {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
125
client_ref.ts
Normal file
125
client_ref.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// import * as oidc from "openid-client";
|
||||||
|
// import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
// dotenv.config();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// process.env
|
||||||
|
// const server = new URL("http://localhost:3000");
|
||||||
|
|
||||||
|
// // Discover the provider metadata
|
||||||
|
|
||||||
|
// // const as = await oidc.discovery(server, "application", undefined, undefined, { execute: [oidc.allowInsecureRequests] });
|
||||||
|
|
||||||
|
// // console.dir(as.clientMetadata());
|
||||||
|
|
||||||
|
// // // Register a new client
|
||||||
|
// const client = await oidc.dynamicClientRegistration(server, {
|
||||||
|
// redirect_uris: ["http://foo.com"],
|
||||||
|
// grant_types: ["authorization_code", "refresh_token"],
|
||||||
|
// response_types: ["code"],
|
||||||
|
// token_endpoint_auth_method: "client_secret_basic",
|
||||||
|
// }, undefined, {execute: [oidc.allowInsecureRequests]});
|
||||||
|
|
||||||
|
// console.log(client.serverMetadata())
|
||||||
|
// console.log(client.clientMetadata())
|
||||||
|
|
||||||
|
// const state = oidc.randomState();
|
||||||
|
// const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||||
|
|
||||||
|
// const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
// const authUrl = oidc.buildAuthorizationUrl(as, {
|
||||||
|
// client_id: client.client_id,
|
||||||
|
// redirect_uri: "http://localhost:4000/callback",
|
||||||
|
// response_type: "code",
|
||||||
|
// scope: "openid offline_access",
|
||||||
|
// state,
|
||||||
|
// code_challenge: codeChallenge,
|
||||||
|
// code_challenge_method: "S256",
|
||||||
|
// });
|
||||||
|
|
||||||
|
|
||||||
|
// console.log(Object.getOwnPropertyNames(client));
|
||||||
|
|
||||||
|
import * as oid from 'openid-client'
|
||||||
|
|
||||||
|
// Prerequisites
|
||||||
|
|
||||||
|
let getCurrentUrl!: (...args: any) => URL
|
||||||
|
let server!: URL // Authorization server's Issuer Identifier URL
|
||||||
|
let clientId!: string
|
||||||
|
let clientSecret!: string
|
||||||
|
/**
|
||||||
|
* Value used in the authorization request as redirect_uri pre-registered at the
|
||||||
|
* Authorization Server.
|
||||||
|
*/
|
||||||
|
let redirect_uri!: string
|
||||||
|
|
||||||
|
// End of prerequisites
|
||||||
|
|
||||||
|
let config = await oid.discovery(server, clientId, clientSecret)
|
||||||
|
|
||||||
|
let code_challenge_method = 'S256'
|
||||||
|
/**
|
||||||
|
* The following (code_verifier and potentially nonce) MUST be generated for
|
||||||
|
* every redirect to the authorization_endpoint. You must store the
|
||||||
|
* code_verifier and nonce in the end-user session such that it can be recovered
|
||||||
|
* as the user gets redirected from the authorization server back to your
|
||||||
|
* application.
|
||||||
|
*/
|
||||||
|
let code_verifier = oid.randomPKCECodeVerifier()
|
||||||
|
let code_challenge = await oid.calculatePKCECodeChallenge(code_verifier)
|
||||||
|
let nonce!: string
|
||||||
|
|
||||||
|
{
|
||||||
|
// redirect user to as.authorization_endpoint
|
||||||
|
let parameters: Record<string, string> = {
|
||||||
|
redirect_uri,
|
||||||
|
scope: 'openid email',
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We cannot be sure the AS supports PKCE so we're going to use nonce too. Use
|
||||||
|
* of PKCE is backwards compatible even if the AS doesn't support it which is
|
||||||
|
* why we're using it regardless.
|
||||||
|
*/
|
||||||
|
if (!config.serverMetadata().supportsPKCE()) {
|
||||||
|
nonce = oid.randomNonce()
|
||||||
|
parameters.nonce = nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirectTo = oid.buildAuthorizationUrl(config, parameters)
|
||||||
|
|
||||||
|
console.log('redirecting to', redirectTo.href)
|
||||||
|
// now redirect the user to redirectTo.href
|
||||||
|
}
|
||||||
|
|
||||||
|
// one eternity later, the user lands back on the redirect_uri
|
||||||
|
// Authorization Code Grant
|
||||||
|
let sub: string
|
||||||
|
let access_token: string
|
||||||
|
{
|
||||||
|
let currentUrl: URL = getCurrentUrl()
|
||||||
|
let tokens = await oid.authorizationCodeGrant(config, currentUrl, {
|
||||||
|
pkceCodeVerifier: code_verifier,
|
||||||
|
expectedNonce: nonce,
|
||||||
|
idTokenExpected: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Token Endpoint Response', tokens)
|
||||||
|
;({ access_token } = tokens)
|
||||||
|
let claims = tokens.claims()!
|
||||||
|
console.log('ID Token Claims', claims)
|
||||||
|
;({ sub } = claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfo Request
|
||||||
|
{
|
||||||
|
let userInfo = await oid.fetchUserInfo(config, access_token, sub)
|
||||||
|
|
||||||
|
console.log('UserInfo Response', userInfo)
|
||||||
|
}
|
||||||
1609
package-lock.json
generated
Normal file
1609
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
package.json
Normal file
38
package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "personal-oidc",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "main.ts",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/oidc-provider": "^9.11.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"handlebars": "^4.7.9",
|
||||||
|
"oidc-provider": "^9.11.1",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
|
"uid": "^2.0.2",
|
||||||
|
"undici-types": "^8.3.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node provider.ts",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/sjkillen/personal-oidc.git"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/sjkillen/personal-oidc/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/sjkillen/personal-oidc#readme",
|
||||||
|
"description": "",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/node": "^26.1.2",
|
||||||
|
"@types/openid-client": "^3.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
provider.html
Normal file
19
provider.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authenticate</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{#if login}}
|
||||||
|
<form method="post">
|
||||||
|
<input type="password" placeholder="password" />
|
||||||
|
<input value="Login" type="submit" />
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<form method="post">
|
||||||
|
<input value="Consent to add scope openid" type="submit" />
|
||||||
|
</form>
|
||||||
|
{{/if}}
|
||||||
|
<div>{{details}}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
111
provider.ts
Normal file
111
provider.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import express from "express";
|
||||||
|
import * as oidc from "oidc-provider";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import Handlebars from "handlebars";
|
||||||
|
import assert from "assert";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
dotenv.config({ quiet: true });
|
||||||
|
const AUTH_PROVIDER_PORT = Number(process.env.AUTH_PROVIDER_PORT) || 3000;
|
||||||
|
const ACCOUNT_USERNAME = String(process.env.ACCOUNT_USERNAME) || "unknown_user";
|
||||||
|
const provider_login_page = Handlebars.compile(
|
||||||
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "provider.html"), {
|
||||||
|
encoding: "utf-8",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const provider = new oidc.Provider("http://localhost:3000", {
|
||||||
|
features: {
|
||||||
|
registration: {
|
||||||
|
enabled: true,
|
||||||
|
initialAccessToken: false,
|
||||||
|
issueRegistrationAccessToken: true,
|
||||||
|
},
|
||||||
|
devInteractions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async findAccount(ctx, id) {
|
||||||
|
assert(id == ACCOUNT_USERNAME);
|
||||||
|
return {
|
||||||
|
accountId: id,
|
||||||
|
async claims(use, scope) {
|
||||||
|
return { sub: id };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.get("/interaction/:uid", async (req, res) => {
|
||||||
|
const details = await provider.interactionDetails(req, res);
|
||||||
|
const login = details.prompt.name == "login";
|
||||||
|
res.header("Content-Type", "text/html");
|
||||||
|
res.send(provider_login_page({ login, details: JSON.stringify(details, null, 3) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/interaction/:uid", async (req, res) => {
|
||||||
|
const interactionDetails = await provider.interactionDetails(req, res);
|
||||||
|
const details = interactionDetails.prompt.details;
|
||||||
|
let grant: oidc.Grant | undefined;
|
||||||
|
if (interactionDetails.grantId) {
|
||||||
|
grant = await provider.Grant.find(interactionDetails.grantId);
|
||||||
|
}
|
||||||
|
if (typeof grant == "undefined") {
|
||||||
|
if (
|
||||||
|
interactionDetails.session &&
|
||||||
|
typeof interactionDetails.params.client_id == "string"
|
||||||
|
) {
|
||||||
|
grant = new provider.Grant({
|
||||||
|
accountId: interactionDetails.session.accountId,
|
||||||
|
clientId: interactionDetails.params.client_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let consent: { consent: { grantId: string } } | {} = {};
|
||||||
|
if (typeof grant != "undefined") {
|
||||||
|
if (details.missingOIDCScope instanceof Array) {
|
||||||
|
grant.addOIDCScope(details.missingOIDCScope.join(" "));
|
||||||
|
}
|
||||||
|
if (details.missingOIDCClaims instanceof Array) {
|
||||||
|
grant.addOIDCClaims(details.missingOIDCClaims);
|
||||||
|
}
|
||||||
|
if (details.missingResourceScopes) {
|
||||||
|
for (const [indicator, scopes] of Object.entries(
|
||||||
|
details.missingResourceScopes,
|
||||||
|
)) {
|
||||||
|
grant.addResourceScope(indicator, scopes.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantId = await grant.save();
|
||||||
|
if (interactionDetails.grantId != grantId) {
|
||||||
|
consent = {
|
||||||
|
consent: {
|
||||||
|
grantId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
login: {
|
||||||
|
accountId: ACCOUNT_USERNAME,
|
||||||
|
},
|
||||||
|
...consent,
|
||||||
|
};
|
||||||
|
const error = {
|
||||||
|
error: "access_denied",
|
||||||
|
};
|
||||||
|
return provider.interactionFinished(req, res, result); // result object below
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(provider.callback());
|
||||||
|
|
||||||
|
app.listen(AUTH_PROVIDER_PORT, (err) => {
|
||||||
|
if (!err) {
|
||||||
|
console.log(`Auth provider listening on port ${AUTH_PROVIDER_PORT}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["esnext"],
|
||||||
|
"types": ["node"],
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue