Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Example using node.js instead of nginx to route requests #359

Closed
dionjwa opened this issue Feb 3, 2021 · 2 comments
Closed

Example using node.js instead of nginx to route requests #359

dionjwa opened this issue Feb 3, 2021 · 2 comments

Comments

@dionjwa
Copy link

dionjwa commented Feb 3, 2021

Responding to #305 (comment) as an issue so as not to derail that discussion.

"I have a complete cloud stack template (app + ci + deploy in cloud providers with oauth). There are other oauth systems, but for a flexible simple single oauth service vouch is reliable and simple. I use node.js instead of nginx as the router of requests (maybe that config could be useful to others, idk it feels a pretty rare case). In a sense it replaces using Auth0, Okta, etc, or an integrated OAuth library like http://www.passportjs.org. There's just so much complexity, possible vendor lock-in, expense, etc, that sometimes a tool solving a single task is preferable, at least in the beginning."

Architecture:

Architecture

The auth service (can be a cloud-function/lambda or horizontally scaling pod) handles the redirection that nginx performs in vouch's documentation.

Here is my actual /login route handler, redirecting to vouch when needed. There's some of my app specific stuff in there, feel free to put together into something more generically useful. Unfortunately I don't have time for that, but I'm happy to answer any questions about it:

// env var config
const ORIGIN_VOUCH_INTERNAL: string = env.get('ORIGIN_VOUCH_INTERNAL').required().asString();
const APP_FQDN: string = env.get('APP_FQDN').required().asString();
const APP_PORT: string = env.get('APP_PORT').default('443').asString();
const APP_FQDN_PLUS_PORT: string = `${APP_FQDN}${APP_PORT === "443" ? "" : ":" + APP_PORT}`;
const VOUCH_ORIGIN_EXTERNAL = `https://oauth.${APP_FQDN_PLUS_PORT}`;
const AUTH_ORIGIN_EXTERNAL = `https://${APP_FQDN_PLUS_PORT}`;

const COOKIE_MAX_AGE_SECONDS = parse('1 week', 's');

// ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
// │                                              href                                              │
// ├──────────┬──┬─────────────────────┬────────────────────────┬───────────────────────────┬───────┤
// │ protocol │  │        auth         │          host          │           path            │ hash  │
// │          │  │                     ├─────────────────┬──────┼──────────┬────────────────┤       │
// │          │  │                     │    hostname     │ port │ pathname │     search     │       │
// │          │  │                     │                 │      │          ├─┬──────────────┤       │
// │          │  │                     │                 │      │          │ │    query     │       │
// "  https:   //    user   :   pass   @ sub.example.com : 8080   /p/a/t/h  ?  query=string   #hash "
// │          │  │          │          │    hostname     │ port │          │                │       │
// │          │  │          │          ├─────────────────┴──────┤          │                │       │
// │ protocol │  │ username │ password │          host          │          │                │       │
// ├──────────┴──┼──────────┴──────────┼────────────────────────┤          │                │       │
// │   origin    │                     │         origin         │ pathname │     search     │ hash  │
// ├─────────────┴─────────────────────┴────────────────────────┴──────────┴────────────────┴───────┤
// │                                              href                                              │
// └────────────────────────────────────────────────────────────────────────────────────────────────┘
// (All spaces in the "" line should be ignored. They are purely for formatting.)

export default fp(async (server: FastifyInstanceWithDB, _: PluginMetadata, next: any) => {
    // see https://www.fastify.io/docs/latest/TypeScript/ to type headers and the body
    server.get("/login", {}, async (request: FastifyRequest, reply: FastifyReply) => {
        const urlVouchValidate = `${ORIGIN_VOUCH_INTERNAL}/validate`;
        let vouchResponse: Response<string>;

        const hostDomain :string = request.hostname;
        const referrerDomain :string = request.headers.referer ? new URL(request.headers.referer).hostname : '';

        // Think like a cookie: if we have a development server on a different domain when we redirect after a login
        // we go to the APP_FQDN server NOT the development server (which we want) so set a cookie to tell the
        // non-dev client to redirect to the dev server
        if (hostDomain !== referrerDomain && referrerDomain.endsWith('.localhost')) {
            reply.setCookie('volatile_development_login_cookie', request.headers.referer, { sameSite: 'none', domain: APP_FQDN, maxAge: 10, httpOnly: false, path: '/', secure: true });
            // also tell the development server that they are authenticated, even tho technically they aren't YET
            // but this is the last time we have enough context to tell the dev server
            reply.setCookie(`${referrerDomain}_authenticated`, 'true', { sameSite: 'none', domain: referrerDomain, maxAge: COOKIE_MAX_AGE_SECONDS, httpOnly: false, path: '/', secure: true });
        }

        try {
            // only the cookie needs to be passed along to vouch
            vouchResponse = await got.get(urlVouchValidate, { headers: { cookie: request.headers.cookie }, throwHttpErrors: false });

            if (vouchResponse.statusCode === StatusCodes.UNAUTHORIZED) {
                // create a 302 redirect as per vouch docs
                // normally handled with ngnix config but we need to do it here to
                // magically handle all the different use cases
                // see https://github.com/vouch/vouch-proxy
                // convention
                // we redirect back to THIS endpoint so that we can harvest the vouch JWT and get the user data
                const urlRedirect = `${VOUCH_ORIGIN_EXTERNAL}/login?url=${AUTH_ORIGIN_EXTERNAL}/login&vouch-failcount=&X-Vouch-Token=&error=`;
                console.log('urlRedirect', urlRedirect);

                return reply.redirect(urlRedirect);
            } else if (vouchResponse.statusCode !== StatusCodes.OK) {
                request.log.error(`${urlVouchValidate} status=${vouchResponse.statusCode} body=${vouchResponse.body}`);
                return reply.code(500).send('Internal error ugh');
            }
            // continue the main block
        } catch (err) {
            request.log.error({ error: `${err}` });
            return reply.code(500).send('Internal error ugh');
        }
        // create the user if needed
        // create a new browser cookie session
        // add cookie to cache
        const email: string = vouchResponse.headers["x-vouch-idp-claims-email"] as string;
        const picture: string | undefined = vouchResponse.headers["x-vouch-idp-claims-picture"] as string;
        const vouch_success = vouchResponse.headers["x-vouch-success"] === 'true';
        if (!vouch_success || !email || email === '') {
            request.log.error(`${urlVouchValidate} status=${vouchResponse.statusCode} but no user found`);
            return reply.code(500).send('Internal error ugh');
        }

        try {
            await server.db.UpsertUser({ email, picture });

            const responseGetUser = await server.db.GetUserByEmail({ email });
            if (responseGetUser.users.length == 0) {
                request.log.error(`email=${email} error=Failed to find user after upsert`);
                return reply.code(500).send('Internal error ugh');
            }

            const user = responseGetUser.users[0];
            const userId = user.id;

            const tokenResponse = await server.db.CreateSessionToken({ userId });
            const token: string = tokenResponse.insert_tokens.returning[0].token;
            assert(token);

            // finally everything worked, we have a new app cookie
            // SameSite=None is required for the dev case, but also for things like embedded apps, which is most of my apps so far 🤷
            reply.setCookie(APP_FQDN, token, { sameSite: 'none', domain: APP_FQDN, maxAge: COOKIE_MAX_AGE_SECONDS, httpOnly: true, path: '/', secure: true });
            reply.setCookie(`${APP_FQDN}_authenticated`, 'true', { sameSite: 'none', domain: APP_FQDN, maxAge: COOKIE_MAX_AGE_SECONDS, httpOnly: false, path: '/', secure: true });
            // clients cannot see the above cookie, but it's much easier for clients to know the state
        } catch (err) {
            request.log.error(`Failed to upsert user or insert token email=${email} error=${err}`);
            return reply.code(500).send('Internal error ugh');
        }

        // by default, redirect to the main app. Should this be configurable or dynamic?
        return reply.redirect(`https://${APP_FQDN_PLUS_PORT}`);
    });
    next();
});
@bnfinet
Copy link
Member

bnfinet commented Feb 3, 2021

@dionjwa wow! That's fantastic stuff. Thank you for the clear explanation.

@aaronpk and I have been discussing adding a new section to the README for integrations such as this one. I'm going to open a PR for that.

@bnfinet
Copy link
Member

bnfinet commented Aug 18, 2021

@dionjwa thanks again for the fine work! I've linked to this issue from the README under "advanced configurations"

There's talk of integration with Caddy and other http servers. If that gets built and documented I could see breaking this out into its own section in the docs as well.

Cheers!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants