Skip to content

Commit

Permalink
Merge pull request #3 from agektmr/fedcm
Browse files Browse the repository at this point in the history
Implement FedCM RP functionalities
  • Loading branch information
agektmr authored Feb 2, 2024
2 parents 20a63a3 + d6de916 commit feac0cf
Show file tree
Hide file tree
Showing 16 changed files with 8,599 additions and 9,248 deletions.
1 change: 1 addition & 0 deletions libs/common.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function csrfCheck(req, res, next) {
if (req.header('X-Requested-With') != 'XMLHttpRequest') {
return res.status(400).json({ error: 'invalid access.' });
}
// TODO: If the path starts with `fedcm` also check `Sec-Fetch-Dest: webidentity`.
next();
};

Expand Down
73 changes: 67 additions & 6 deletions libs/db.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,26 @@ store.settings({ ignoreUndefinedProperties: true });
**/

export const Users = {
create: async (username) => {
const picture = new URL('https://www.gravatar.com/');
picture.pathname = `/avatar/${crypto.createHash('md5').update(username).digest('hex')}`;
picture.searchParams.append('s', 200);
create: async (username, options = {}) => {
let { picture, displayName, email } = options;
if (!picture) {
const pictureURL = new URL('https://www.gravatar.com/');
pictureURL.pathname = `/avatar/${crypto.createHash('md5').update(username).digest('hex')}`;
pictureURL.searchParams.append('s', 200);
picture = pictureURL.toString();
}
if (!displayName) {
displayName = username;
}
if (!email) {
email = username;
}
const user = {
id: isoBase64URL.fromBuffer(crypto.randomBytes(32)),
username,
picture: picture.toString(),
displayName: username,
picture,
displayName,
email,
};
return Users.update(user);
},
Expand Down Expand Up @@ -132,3 +143,53 @@ export const SessionStore = new FirestoreStore({
dataset: store,
kind: process.env.SESSIONS_COLLECTION,
});

/*
{
user_id: string
issuer: string
subject: string
name: string
email: string
given_name: string
family_name: string
picture: string
issued_at: number
expires_at: number
}
*/
export const FederationMappings = {
create: async (user_id, options) => {
},
findByIssuer: async (url) => {
},
findByUserId: async (user_id) => {
}
};

export const RelyingParties = {
rps: [{
url: 'https://fedcm-rp-demo.glitch.me',
client_id: 'fedcm-rp-demo',
name: 'FedCM RP Demo'
}],
findByClientID: async (client_id) => {
const rp = RelyingParties.rps.find(rp => rp.client_id === client_id);
return Promise.resolve(structuredClone(rp));
}
};

export const IdentityProviders = {
idps: [{
origin: 'https://fedcm-idp-demo.glitch.me',
configURL: 'https://fedcm-idp-demo.glitch.me/fedcm.json',
clientId: 'https://identity-demos.dev',
secret: 'xxxxx'
}],
findByURL: async (url) => {
const idp = IdentityProviders.idps.find(idp => {
return idp.origin === (new URL(url)).origin;
})
return Promise.resolve(structuredClone(idp));
}
};
151 changes: 151 additions & 0 deletions middlewares/fedcm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* @license
* Copyright 2024 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/

import express from 'express';
const router = express.Router();
import { Users } from '../libs/db.mjs';
import * as jwt from 'jsonwebtoken';
import { csrfCheck, sessionCheck } from '../libs/common.mjs';

router.use(express.json());

router.get('/config.json', (req, res) => {
return res.json({
"accounts_endpoint": "/fedcm/accounts",
"client_metadata_endpoint": "/fedcm/metadata",
"id_assertion_endpoint": "/fedcm/idtokens",
"disconnect_endpoint": "/fedcm/disconnect",
"login_url": "/identifier-first-form",
"branding": {
"background_color": "#6200ee",
"color": "#ffffff",
"icons": [{
"url": "https://cdn.glitch.global/3e0c8298-f17f-4c5b-89ea-f93a6f29cb1e/icon.png?v=1654655899873",
"size": 256,
}]
}
});
});

router.get('/accounts', csrfCheck, sessionCheck, (req, res) => {
const user = res.locals.user;

if (user.status === 'session_expired') {
return res.status(401).json({ error: 'not signed in');
}

return res.json({
accounts: [{
id: user.id,
name: user.displayName,
email: user.username,
picture: user.picture,
approved_clients: []
}]
});
});

router.get('/metadata', (req, res) => {
return res.json({
privacy_policy_url: `${process.env.ORIGIN}/privacy_policy`,
terms_of_service_url:`${process.env.ORIGIN}/terms_of_service`
});
});

router.post('/idtokens', csrfCheck, sessionCheck, (req, res) => {
const { client_id, nonce, account_id, consent_acquired, disclosure_text_shown } = req.body;
let user = res.locals.user;

// TODO: Revisit the hardcoded RP client ID handling

// If the user did not consent or the account does not match who is currently signed in, return error.
if (client_id !== RP_CLIENT_ID ||
account_id !== user.id ||
!isValidOrigin(new URL(req.headers.origin).toString())) {
console.error('Invalid request.', req.body);
return res.status(400).json({ error: 'Invalid request.' });
}

if (consent_acquired === 'true' ||
disclosure_text_shown ==='true' ||
!user.approved_clients.includes(RP_CLIENT_ID)) {
console.log('The user is registering to the RP.');
user.approved_clients.push(RP_CLIENT_ID);
Users.update(user);
} else {
console.log('The user is signing in to the RP.');
}

if (user.status === '') {
const token = jwt.sign({
iss: process.env.ORIGIN,
sub: user.id,
aud: client_id,
nonce,
exp: new Date().getTime()+IDTOKEN_LIFETIME,
iat: new Date().getTime(),
name: `${user.displayName}`,
email: user.username,
picture: user.picture
}, process.env.SECRET);

return res.json({ token });

} else {
let error_code = 401;
switch (user.status) {
case 'server_error':
error_code = 500;
break;
case 'temporarily_unavailable':
error_code = 503;
break;
default:
error_code = 401;
}
return res.status(error_code).json({
error: {
code: user.status,
url: `${process.env.ORIGIN}/error.html&type=${user.status}`
}
});
}
});

router.post('/disconnect', csrfCheck, sessionCheck, (req, res) => {
const { account_hint, client_id } = req.body;

const user = res.locals.user;

// TODO: Use PPID instead
if (account_hint !== user.id) {
console.error("Account hint doesn't match.");
return res.status(401).json({ error: "Account hint doesn't match." });
}

if (!user.approved_clients.has(client_id)) {
console.error('The client is not connected.');
return res.status(400).json({ error: 'The client is not connected.' });
}

// Remove the client ID from the `approved_clients` list.
user.approved_clients = user.approved_clients.filter(_client_id => _client_id !== client_id);
Users.update(user);
return res.json({ account_id: user.id });
});

export { router as fedcm };
102 changes: 102 additions & 0 deletions middlewares/federation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* @license
* Copyright 2024 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/

import express from 'express';
const router = express.Router();
import { Users, IdentityProviders, FederationMappings } from '../libs/db.mjs';
import jwt from 'jsonwebtoken';
import { csrfCheck, sessionCheck } from '../libs/common.mjs';

router.use(express.json());

router.post('/idp', async (req, res) => {
const { url } = req.body;
const idp = await IdentityProviders.findByURL(url);
if (!idp) {
return res.status(404).json({ error: 'No matching identity provider found.' });
}
delete idp.secret;
return res.json(idp);
});

router.post('/verify', csrfCheck, async (req, res) => {
const { token: raw_token, url } = req.body;
// console.error(raw_token);

try {
const expected_nonce = req.session.nonce.toString();

const idp = await IdentityProviders.findByURL(url);

const token = jwt.verify(raw_token, idp.secret, {
issuer: idp.origin,
nonce: expected_nonce,
audience: idp.clientId
});

/*
Example JWT:
{
"iss": "https://fedcm-idp-demo.glitch.me",
"sub": "9KfiqUb2N0fhlffvzhO3DoZl2WipjVDhjgefWDzR1Rw",
"aud": "https://identity-demos.dev",
"nonce": "81805668362",
"exp": 1706941073707,
"iat": 1706854673707,
"name": "Elisa Beckett",
"email": "demo@example.com",
"given_name": "Elisa",
"family_name": "Beckett",
"picture": "https://gravatar.com/avatar/e0c52c473bfcdb168d3b183699668f96a4fa1ac19534b8e96fe215dcaf36ef02?size=256"
}
*/

// Find a matching user by querying with the email address
// TODO: Beware that the email is verified.
let user = await Users.findByUsername(token.email);
if (user) {
const map = FederationMappings.findByIssuer(token.iss);
if (!map) {
// If the email address matches, merge the user.
FederationMappings.create(user.id, token);
} else {
// TODO: Think about how each IdP provided properties match against RP's.
}
} else {
// If the user does not exist yet, create a new user.
user = Users.create(token.email, {
email: token.email,
displayName: token.name,
picture: token.picture
});
FederationMappings.create(user.id, token);
}

req.session.username = user.username;
req.session['signed-in'] = 'yes';

// Set a login status using the Login Status API
res.set('Set-Login', 'logged-in');

return res.status(200).json(user);
} catch (e) {
console.error(e.message);
return res.status(401).json({ error: 'ID token verification failed.'});
}
});

export { router as federation };
7 changes: 7 additions & 0 deletions middlewares/well-known.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ router.get('/assetlinks.json', (req, res) => {
return res.json(assetlinks);
});

router.get('/webidentity', (req, res) => {
const fedcm_config_url = `${process.env.ORIGIN}/fedcm/config.json`;
return res.json({
"provider_urls": [ fedcm_config_url ]
});
});

router.get('/passkey-endpoints', (req, res) => {
const web_endpoint = `${process.env.ORIGIN}/home`;
return res.json({ enroll: web_endpoint, manage: web_endpoint });
Expand Down
Loading

0 comments on commit feac0cf

Please sign in to comment.