Skip to content
This repository has been archived by the owner on Dec 30, 2024. It is now read-only.

Commit

Permalink
adding oidc-provider
Browse files Browse the repository at this point in the history
  • Loading branch information
dvir-daniel committed Nov 12, 2024
1 parent c3f4d11 commit 0493f57
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 165 deletions.
71 changes: 71 additions & 0 deletions apps/account/src/lib/external-oidc/findAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { KoaContextWithOIDC, Account, ClaimsParameterMember, FindAccount, AccountClaims } from 'oidc-provider';
import { fdb } from '../googlecloud/db';

interface LocalAccount {
id: string;
displayName?: string;
email?: string;
verifiedEmails?: string[]; // Array of verified emails
phone?: string;
verifiedPhones?: string[]; // Array of verified phones
birthdate?: string;
firstName?: string;
lastName?: string;
locale?: string;
photoURL?: string;
}

async function retrieveAccountById(id: string): Promise<LocalAccount | null> {
try {
const snapshot = await fdb.doc(`users/${id}`).get();
const accountData = snapshot.data();

if (!accountData) return null;

return {
id,
displayName: accountData.displayName,
email: accountData.email,
verifiedEmails: accountData.verifiedEmails,
phone: accountData.phone,
verifiedPhones: accountData.verifiedPhones,
birthdate: accountData.birthdate,
firstName: accountData.firstName,
lastName: accountData.lastName,
locale: accountData.locale,
photoURL: accountData.photoURL,
};
} catch (error) {
console.error(`Error retrieving account with ID ${id}:`, error);
return null;
}
}

async function findAccount(ctx: KoaContextWithOIDC, id: string): Promise<Account | undefined> {
const account = await retrieveAccountById(id);

if (!account) return undefined;

return {
accountId: account.id,
async claims(use: string, scope: string): Promise<AccountClaims> {
const claims: AccountClaims = {
sub: account.id ?? id,
id: account.id ?? id,
displayName: account.displayName,
email: account.email,
verifiedEmails: account.verifiedEmails,
phone: account.phone,
verifiedPhones: account.verifiedPhones,
birthdate: account.birthdate,
firstName: account.firstName,
lastName: account.lastName,
locale: account.locale,
photoURL: account.photoURL,
};
return claims;
},
};
}

export default findAccount;
29 changes: 6 additions & 23 deletions apps/account/src/lib/external-oidc/google-cloud-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
import { Adapter, AdapterPayload } from 'oidc-provider';
import { fdb, rdb } from '../googlecloud/db';

function sanitizePayload(payload: Record<string, any>): Record<string, any> {
return JSON.parse(JSON.stringify(payload)); // Serializing removes undefined values
}

export default class GoogleCloudAdapter implements Adapter {
private name: string;

Expand All @@ -13,9 +17,10 @@ export default class GoogleCloudAdapter implements Adapter {
* Save data to Firebase with expiration.
*/
async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
const sanitizedPayload = sanitizePayload(payload); // Remove undefined values
const ref = rdb.ref(`${this.name}/${id}`);
await ref.set({
...payload,
...sanitizedPayload,
expiresAt: Date.now() + expiresIn * 1000,
});
ref.onDisconnect().remove(); // Ensures data removal if the client disconnects
Expand Down Expand Up @@ -135,26 +140,4 @@ export default class GoogleCloudAdapter implements Adapter {

await rdb.ref().update(updates);
}

/**
* Find an account by ID. Uses Firestore as the data source.
*/
async findAccount(ctx: any, id: string): Promise<{ accountId: string; claims: () => Promise<any> } | undefined> {
const snapshot = await fdb.doc(`users/${id}`).get();
const accountData = snapshot.data();

if (!accountData) return undefined;

return {
accountId: id,
claims: async () => ({
sub: id,
uid: accountData.uid,
displayName: accountData.displayName,
email: accountData.email,
photoURL: accountData.photoURL,
emailVerified: accountData.email_verified,
}),
};
}
}
125 changes: 63 additions & 62 deletions apps/account/src/lib/external-oidc/index.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
// lib/oidcProvider.ts
import { Provider } from 'oidc-provider';
import GoogleCloudAdapter from './google-cloud-adapter';
import subscribe from './events-listeners.js'

const baseURL = process.env.APP_URL
import { Provider, KoaContextWithOIDC, Account } from 'oidc-provider';
import GoogleCloudAdapter from './google-cloud-adapter';
import subscribe from './events-listeners.js';
import findAccount from './findAccount';

/**
* Configures and creates an OIDC provider instance with custom options.
* This provider handles user authentication, token management, and more.
*/
export function createOidcProvider() {
const oidc = new Provider('https://account.eartho.io', {
// Adapter for data persistence in Google Cloud
adapter: GoogleCloudAdapter,

// No pre-configured clients, clients should be registered as needed
clients: [],
formats: {
customizers: {
jwt: (ctx, token, parts) => {
if (token.kind === 'AccessToken') {
return { ...parts, header: { ...parts.header, alg: 'RS256' }, payload: { ...parts.payload } };
}
return parts;
},
},
},

// Define routes for various OIDC endpoints
routes: {
authorization: '/api/oidc/auth',
token: '/api/oidc/token',
Expand All @@ -30,102 +29,104 @@ export function createOidcProvider() {
end_session: '/api/oidc/session/end',
device_authorization: '/api/oidc/device/code',
},

// Default settings for client configurations
clientDefaults: {
grant_types: [
'authorization_code',
'refresh_token',
'client_credentials',
'urn:ietf:params:oauth:grant-type:device_code',
'urn:ietf:params:oauth:grant-type:jwt-bearer',
'implicit'
'implicit',
],
response_types: ['code', 'id_token'],
},
clientBasedCORS(ctx, origin, client) {
return true;
},
features: {
devInteractions: { enabled: false },

// Allow CORS requests from any origin
clientBasedCORS: () => true,

deviceFlow: { enabled: true },
// Enable and configure OIDC features
features: {
devInteractions: { enabled: false },
deviceFlow: { enabled: true },
revocation: { enabled: true },
introspection: { enabled: true },
webMessageResponseMode: { enabled: true },
claimsParameter: { enabled: true }
claimsParameter: { enabled: true },
},

// Enables rotating refresh tokens
rotateRefreshToken: true,

// Defines the available claims and their scopes
claims: {
address: ['address'],
email: ['email', 'verifiedEmails'],
phone: ['phone', 'verifiedPhone'],
profile: ['id', 'birthdate', 'firstName', 'gender', 'lastName', 'locale', 'displayName', 'photoURL'],
},
// Define extra parameters for handling scope dynamically
extraParams: {
scope(ctx, value, client) {
if (ctx.oidc.params) {
// Set default scope if not provided
ctx.oidc.params.scope ||= value || client.scope || 'openid profile email';
}
},
},
// Interaction URLs, with client_id and interaction UID as parameters
interactions: {
url: (ctx, interaction): string => {
url: (ctx: KoaContextWithOIDC, interaction): string => {
const params = new URLSearchParams();

const clientId = ctx.oidc.params?.client_id;
if (typeof clientId === 'string') {
params.set('client_id', clientId);
}

// Add the interaction UID as a parameter
if (clientId) params.set('client_id', clientId.toString());
params.set('interaction', interaction.uid);

// Construct the final URL with all query parameters
return `/connect/${interaction.prompt.name}?${params.toString()}`;
},
},

findAccount: async (ctx, id) => {
// This should be replaced with actual account retrieval logic
return {
accountId: id,
async claims(use, scope) {
return { sub: id, name: 'Example User', email: '[email protected]' };
},
};
},
// Reference to the account retrieval logic
findAccount,

// Cookie configuration for session management
cookies: {
long: { signed: true, secure: false, path: '/', sameSite: 'lax' },
short: { signed: true, secure: false, path: '/', sameSite: 'lax' },
keys: [process.env.COOKIE_SECRET!],
long: { signed: true, secure: process.env.NODE_ENV === 'production', path: '/', sameSite: 'lax' },
short: { signed: true, secure: process.env.NODE_ENV === 'production', path: '/', sameSite: 'lax' },
keys: [process.env.COOKIE_SECRET ?? (() => { throw new Error("Missing COOKIE_SECRET") })()],
},

// JSON Web Key Set (JWKS) for token signing, loaded from environment variables
jwks: {
keys: [
{
kty: 'RSA',
kid: process.env.JWKS_KID, // Load from environment
kid: process.env.JWKS_KID!,
alg: 'RS256',
use: 'sig',
e: 'AQAB',
n: process.env.JWKS_PUBLIC_KEY, // Load public key modulus from environment
d: process.env.JWKS_PRIVATE_KEY_D, // Private key component
p: process.env.JWKS_PRIVATE_KEY_P, // Prime factor p
q: process.env.JWKS_PRIVATE_KEY_Q, // Prime factor q
dp: process.env.JWKS_PRIVATE_KEY_DP,
dq: process.env.JWKS_PRIVATE_KEY_DQ,
qi: process.env.JWKS_PRIVATE_KEY_QI,
n: process.env.JWKS_PUBLIC_KEY!,
d: process.env.JWKS_PRIVATE_KEY_D!,
p: process.env.JWKS_PRIVATE_KEY_P!,
q: process.env.JWKS_PRIVATE_KEY_Q!,
dp: process.env.JWKS_PRIVATE_KEY_DP!,
dq: process.env.JWKS_PRIVATE_KEY_DQ!,
qi: process.env.JWKS_PRIVATE_KEY_QI!,
},
],
},
});

subscribe(oidc);
// Uncomment the following line to attach event listeners, if needed
// subscribe(oidc);

const { invalidate: orig } = oidc.Client.Schema.prototype;

oidc.Client.Schema.prototype.invalidate = function invalidate(message, code) {
if (code === 'implicit-force-https' || code === 'implicit-forbid-localhost') {
return;
// Override the invalidate method to skip validation in specific cases
const { invalidate: originalInvalidate } = (oidc.Client as any).Schema.prototype;
(oidc.Client as any).Schema.prototype.invalidate = function (message: any, code: string) {
if (code !== 'implicit-force-https' && code !== 'implicit-forbid-localhost') {
originalInvalidate.call(this, message);
}

orig.call(this, message);
};


return oidc;
}


7 changes: 6 additions & 1 deletion apps/account/src/middleware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { SUPPORTED_LOCALES } from './i18n';
export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();

// Skip middleware for API routes
if (url.pathname.startsWith('/api') || url.pathname.startsWith('/.well-known')) {
return NextResponse.next();
}

const acceptLanguage = req.headers.get('accept-language');
const cookieLocale = req.cookies.get('NEXT_LOCALE')?.value;

Expand All @@ -17,7 +22,7 @@ export function middleware(req: NextRequest) {
if(!SUPPORTED_LOCALES.includes(finalLocale)){
finalLocale = 'en';
}
const res = NextResponse.redirect(url);
const res = NextResponse.next();
res.cookies.set('NEXT_LOCALE', finalLocale, { maxAge: 60 * 60 * 24 * 365 }); // 1 year
return res;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/account/src/pages/api/oidc/[...slug].ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// pages/api/oidc/[...slug].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { createOidcProvider } from '@/lib/external-oidc';
import { auth } from '@/auth';
import { serialize } from 'cookie';

const provider = createOidcProvider();

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { slug } = req.query;

// Set caching headers to disable caching
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
Expand Down
Loading

0 comments on commit 0493f57

Please sign in to comment.