Skip to content

Commit

Permalink
Merge pull request #55 from bokub/cloudflare
Browse files Browse the repository at this point in the history
Migrate from Vercel to Cloudflare Pages
  • Loading branch information
bokub authored Jul 12, 2024
2 parents 8c81617 + 251412a commit b174fb4
Show file tree
Hide file tree
Showing 16 changed files with 732 additions and 211 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ node_modules
dist
.vercel
test.md
.wrangler
.dev.vars
95 changes: 0 additions & 95 deletions api/[type].ts

This file was deleted.

13 changes: 0 additions & 13 deletions api/auth.ts

This file was deleted.

18 changes: 0 additions & 18 deletions api/callback.ts

This file was deleted.

115 changes: 115 additions & 0 deletions functions/api/[type].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import axios from 'axios';
import * as qs from 'qs';
import pino from 'pino';
import { z } from 'zod';
import { getAPIToken } from '../../lib/token';
import { dataPoints, dataURLs } from '../../lib/url';
import { isTokenValid } from '../../lib/auth';
import { Env } from '../../lib/env';

const logger = pino();

const schema = z.object({
type: z.enum(dataPoints),
prm: z.string().length(14),
start: z.string().regex(/20[0-9]{2}-[0-9]{2}-[0-9]{2}/),
end: z.string().regex(/20[0-9]{2}-[0-9]{2}-[0-9]{2}/),
});

export const onRequest: PagesFunction<Env> = async ({ request: req, params, env }) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 200,
});
}
if (req.method !== 'GET') {
return Response.json({ status: 405, message: 'Seule la méthode GET est autorisée' }, { status: 405 });
}

const { BASE_URL, JWT_SECRET } = env;
const { searchParams } = new URL(req.url);

// Validate input
const input = schema.safeParse({
type: params.type,
prm: searchParams.get('prm'),
start: searchParams.get('start'),
end: searchParams.get('end'),
});

if (input.success === false) {
return Response.json(
{
status: 400,
message: 'Paramètres invalides',
error: input.error,
},
{ status: 400 }
);
}

const { type, prm, start, end } = input.data;

// Validate user token
const authHeader = req.headers.get('Authorization');
const userToken = authHeader?.split(' ')[1];
if (!userToken) {
return Response.json(
{ status: 400, message: "Le header 'Authorization' est manquant ou invalide" },
{ status: 400 }
);
}
if (!(await isTokenValid(userToken, prm, JWT_SECRET))) {
return Response.json(
{ status: 401, message: "Votre token est invalide ou ne permet pas d'accéder à ce PRM" },
{ status: 401 }
);
}

// Get Enedis token
let apiToken: string;
try {
apiToken = await getAPIToken(env);
} catch (e) {
logger.error({ message: 'cannot refresh token', error: e });
return Response.json(
{ status: 500, message: 'Impossible de rafraîchir le token. Réessayez plus tard' },
{ status: 500 }
);
}

// Fetch data
try {
const response = await fetch(
`${BASE_URL}/${dataURLs[type]}?${qs.stringify({
start,
end,
usage_point_id: prm,
})}`,
{
headers: {
Accept: 'application/json',
Authorization: 'Bearer ' + apiToken,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);

if (!response.ok) {
return Response.json(
{
status: response.status,
message: 'The Enedis API returned an error',
error: await response.json(),
},
{ status: response.status }
);
}

const data: { meter_reading: any } = await response.json();
return Response.json(data.meter_reading);
} catch (e) {
logger.error({ message: 'cannot call Enedis', error: e });
return Response.json({ status: 500, message: 'Erreur inconnue. Réessayez plus tard' }, { status: 500 });
}
};
11 changes: 11 additions & 0 deletions functions/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Env } from '../../lib/env';

export const onRequest: PagesFunction<Env> = async ({ request: req, env }) => {
const state = 'v2_' + Array.from({ length: 8 }, () => Math.random().toString(36)[2]).join('');
const baseURI = env.SANDBOX ? 'https://ext.hml.api.enedis.fr' : 'https://mon-compte-particulier.enedis.fr';

return Response.redirect(
`${baseURI}/dataconnect/v1/oauth2/authorize` +
`?client_id=${env.CLIENT_ID}&state=${state}&duration=P3Y&response_type=code`
);
};
24 changes: 24 additions & 0 deletions functions/api/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { generateToken } from '../../lib/auth';
import { Env } from '../../lib/env';
import { z } from 'zod';
import pino from 'pino';
const logger = pino();

export const onRequest: PagesFunction<Env> = async ({ request: req, env }) => {
try {
const { searchParams } = new URL(req.url);
const queryPRMs = z.string().parse(searchParams.get('usage_point_id')).split(/[,;]/g);

const authToken = await generateToken(queryPRMs, env.JWT_SECRET);
return new Response('/token', {
status: 302,
headers: {
Location: '/token',
'Set-Cookie': `conso-token=${authToken}; SameSite=Strict; Path=/`,
},
});
} catch (e) {
logger.error({ message: 'cannot generate Auth token', error: e });
return Response.json({ status: 500, message: 'internal server error' }, { status: 500 });
}
};
9 changes: 9 additions & 0 deletions functions/api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["esnext"],
"types": ["@cloudflare/workers-types"],
"moduleResolution": "node"
}
}
19 changes: 11 additions & 8 deletions lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import jwt from 'jsonwebtoken';
import * as jose from 'jose';

const { JWT_SECRET } = process.env as { JWT_SECRET: string };
const alg = 'HS256';

export function generateToken(usagePointIds: string[]) {
return jwt.sign({ sub: usagePointIds, exp: Math.floor(Date.now() / 1000) + 3 * 364 * 24 * 3600 }, JWT_SECRET, {
algorithm: 'HS256',
});
export async function generateToken(usagePointIds: string[], secret: string) {
return await new jose.SignJWT({})
.setProtectedHeader({ alg })
.setIssuedAt()
.setExpirationTime(`${364 + 365 + 365}d`)
.setSubject(usagePointIds as any)
.sign(new TextEncoder().encode(secret));
}

export function isTokenValid(token: string, usagePointId: string): boolean {
export async function isTokenValid(token: string, usagePointId: string, secret: string): Promise<boolean> {
try {
const decoded = jwt.verify(token, JWT_SECRET);
const { payload: decoded } = await jose.jwtVerify(token, new TextEncoder().encode(secret));
return Array.isArray(decoded.sub) && decoded.sub.includes(usagePointId);
} catch (err) {
return false;
Expand Down
10 changes: 10 additions & 0 deletions lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { KVNamespace } from '@cloudflare/workers-types';

export interface Env {
BASE_URL: string;
JWT_SECRET: string;
CLIENT_ID: string;
CLIENT_SECRET: string;
SANDBOX: string | undefined;
CONSO_API: KVNamespace;
}
45 changes: 27 additions & 18 deletions lib/token.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
import axios from 'axios';
import * as qs from 'qs';
import pino from 'pino';
import { Env } from './env';

const logger = pino();
const { BASE_URL, CLIENT_ID, CLIENT_SECRET } = process.env;

let token: string;
let tokenExpiration: number;
const KV_KEY = 'access_token';

export async function getAPIToken(): Promise<string> {
if (token && new Date().getTime() < tokenExpiration) {
export async function getAPIToken(env: Env): Promise<string> {
const token = await env.CONSO_API.get(KV_KEY);
if (token) {
return token;
}

const { data } = await axios({
method: 'post',
url: `${BASE_URL}/oauth2/v3/token?${qs.stringify({
const response = await fetch(
`${env.BASE_URL}/oauth2/v3/token?${qs.stringify({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
client_id: env.CLIENT_ID,
client_secret: env.CLIENT_SECRET,
})}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);

if (!response.ok) {
return Promise.reject(response);
}

const data: { access_token: string } = await response.json();
logger.info({ message: 'token refreshed', token: data.access_token });

token = data.access_token;
tokenExpiration = new Date().getTime() + 3 * 3600 * 1000; // token expiration is 3 hours later
// Save in KV store
await env.CONSO_API.put(KV_KEY, data.access_token, {
expirationTtl: 3 * 3600, // token expiration is 3 hours later
});

return token;
return data.access_token;
}
Loading

0 comments on commit b174fb4

Please sign in to comment.