vouch / vouch-proxy

an SSO and OAuth / OIDC login solution for Nginx using the auth_request module

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Example using node.js instead of nginx to route requests

dionjwa opened this issue · comments

commented

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();
});

@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.

@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!