mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-02-19 13:00:32 -05:00
187 lines
5.9 KiB
TypeScript
187 lines
5.9 KiB
TypeScript
import fastifyOauth2, { type OAuth2Namespace } from '@fastify/oauth2';
|
|
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
|
import { Type } from '@sinclair/typebox';
|
|
import { Value } from '@sinclair/typebox/value';
|
|
import fp from 'fastify-plugin';
|
|
|
|
import {
|
|
API_LOCATION,
|
|
AUTH0_CLIENT_ID,
|
|
AUTH0_CLIENT_SECRET,
|
|
AUTH0_DOMAIN,
|
|
COOKIE_DOMAIN,
|
|
HOME_LOCATION
|
|
} from '../utils/env';
|
|
import { findOrCreateUser } from '../routes/helpers/auth-helpers';
|
|
import { createAccessToken } from '../utils/tokens';
|
|
import {
|
|
getLoginRedirectParams,
|
|
getPrefixedLandingPath
|
|
} from '../utils/redirection';
|
|
|
|
declare module 'fastify' {
|
|
interface FastifyInstance {
|
|
auth0OAuth: OAuth2Namespace;
|
|
}
|
|
}
|
|
|
|
const Auth0ErrorSchema = Type.Object({
|
|
data: Type.Object({
|
|
payload: Type.Object({
|
|
error: Type.String()
|
|
})
|
|
})
|
|
});
|
|
|
|
/**
|
|
* Fastify plugin for Auth0 authentication. This uses fastify-plugin to expose
|
|
* the auth0OAuth decorator (for easier testing), but to maintain encapsulation
|
|
* it should be registered in a plugin. That prevents auth0OAuth from being
|
|
* available globally.
|
|
*
|
|
* @param fastify - The Fastify instance.
|
|
* @param _options - The plugin options.
|
|
* @param done - The callback function.
|
|
*/
|
|
export const auth0Client: FastifyPluginCallbackTypebox = fp(
|
|
(fastify, _options, done) => {
|
|
void fastify.register(fastifyOauth2, {
|
|
name: 'auth0OAuth',
|
|
scope: ['openid', 'email', 'profile'],
|
|
credentials: {
|
|
client: {
|
|
id: AUTH0_CLIENT_ID,
|
|
secret: AUTH0_CLIENT_SECRET
|
|
}
|
|
},
|
|
discovery: { issuer: `https://${AUTH0_DOMAIN}` },
|
|
callbackUri: `${API_LOCATION}/auth/auth0/callback`,
|
|
cookie: {
|
|
// It's important not to sign the cookie, since the OAuth2 plugin will
|
|
// not unsign it.
|
|
signed: false
|
|
}
|
|
});
|
|
|
|
void fastify.register(function (fastify, _options, done) {
|
|
// TODO(Post-MVP): move this into the app, so that we add this hook once for
|
|
// all auth routes.
|
|
fastify.addHook('onRequest', fastify.redirectIfSignedIn);
|
|
|
|
fastify.get('/signin', async function (request, reply) {
|
|
const returnTo = request.headers.referer ?? `${HOME_LOCATION}/learn`;
|
|
void reply.setCookie('login-returnto', returnTo, {
|
|
domain: COOKIE_DOMAIN,
|
|
httpOnly: true,
|
|
secure: true,
|
|
signed: true,
|
|
sameSite: 'lax'
|
|
});
|
|
|
|
const redirectUrl = await this.auth0OAuth.generateAuthorizationUri(
|
|
request,
|
|
reply
|
|
);
|
|
void reply.redirect(redirectUrl);
|
|
});
|
|
done();
|
|
});
|
|
|
|
// TODO: use a schema to validate the query params.
|
|
fastify.get('/auth/auth0/callback', async function (req, reply) {
|
|
const logger = fastify.log.child({ req, res: reply });
|
|
|
|
const { error, error_description } = req.query as Record<string, string>;
|
|
if (error === 'access_denied') {
|
|
const blockedByLaw =
|
|
error_description === 'Access denied from your location';
|
|
if (blockedByLaw) {
|
|
logger.info('Access denied due to user location');
|
|
return reply.redirect(`${HOME_LOCATION}/blocked`);
|
|
} else {
|
|
logger.info('Authentication failed for user:' + error_description);
|
|
return reply.redirectWithMessage(`${HOME_LOCATION}/learn`, {
|
|
type: 'info',
|
|
content: error_description ?? 'Authentication failed'
|
|
});
|
|
}
|
|
}
|
|
|
|
const { returnTo, pathPrefix, origin } = getLoginRedirectParams(req);
|
|
const redirectBase = getPrefixedLandingPath(origin, pathPrefix);
|
|
|
|
let token;
|
|
try {
|
|
token = (
|
|
await this.auth0OAuth.getAccessTokenFromAuthorizationCodeFlow(req)
|
|
).token;
|
|
} catch (error) {
|
|
// This is the plugin's error message. If it changes, we will either
|
|
// have to update the test or write custom state create/verify
|
|
// functions.
|
|
if (error instanceof Error && error.message === 'Invalid state') {
|
|
logger.error('Auth failed: invalid state');
|
|
} else if (Value.Check(Auth0ErrorSchema, error)) {
|
|
const errorType = error.data.payload.error;
|
|
logger.error(error, 'Auth failed: ' + errorType);
|
|
} else {
|
|
logger.error(error, 'Failed to get access token from Auth0');
|
|
fastify.Sentry.captureException(error);
|
|
}
|
|
// It's important _not_ to redirect to /signin here, as that could
|
|
// create an infinite loop.
|
|
return reply.redirectWithMessage(returnTo, {
|
|
type: 'danger',
|
|
content: 'flash.generic-error'
|
|
});
|
|
}
|
|
|
|
let email;
|
|
try {
|
|
const userinfo = (await fastify.auth0OAuth.userinfo(token)) as {
|
|
email: string;
|
|
};
|
|
logger.info(`Auth0 userinfo: ${JSON.stringify(userinfo)}`);
|
|
email = userinfo.email;
|
|
if (typeof email !== 'string') {
|
|
return reply.redirectWithMessage(returnTo, {
|
|
type: 'danger',
|
|
content: 'flash.no-email-in-userinfo'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error(error, 'Failed to get userinfo from Auth0');
|
|
fastify.Sentry.captureException(error);
|
|
return reply.redirectWithMessage(returnTo, {
|
|
type: 'danger',
|
|
content: 'flash.generic-error'
|
|
});
|
|
}
|
|
|
|
const { id, acceptedPrivacyTerms } = await findOrCreateUser(
|
|
fastify,
|
|
email
|
|
);
|
|
|
|
reply.setAccessTokenCookie(createAccessToken(id));
|
|
|
|
if (acceptedPrivacyTerms) {
|
|
void reply.redirectWithMessage(returnTo, {
|
|
type: 'success',
|
|
content: 'flash.signin-success'
|
|
});
|
|
} else {
|
|
void reply.redirectWithMessage(`${redirectBase}/email-sign-up`, {
|
|
type: 'success',
|
|
content: 'flash.signin-success'
|
|
});
|
|
}
|
|
});
|
|
|
|
done();
|
|
},
|
|
// TODO(Post-MVP): remove bouncer dependency when moving redirectIfSignedIn
|
|
// out of this plugin.
|
|
{ dependencies: ['redirect-with-message', 'bouncer'] }
|
|
);
|