Adding SSO to a self hosted instance of ZROK

I have been meaning to get to this for quite some time. But I have added the capability for my users to add SSO to their endpoints (In our case, my org is using ADFS to supply SAML, OIDC, OAUTH, etc...)

I hope this is okay, but because this has several config files and code, I am bring them into multiple posts instead of one long post. I hope it will make it easier to follow.

My original journey allowed me to implement the caddy backend after compiling the zrok binary to include caddy-security, after getting this to work, caddy-security was baked into the zrok binary caddy backend... I also had several conversations with @michael.quigley @TheLumberjack:

However I ended up NOT liking this method, as end users would need to include the OIDC token/secret in their configs, and if these values are being issued out to individuals, then you might as well consider it not secure. So I started exploring how I could add SSO/SAML/OIDC on the backend without an end user requiring to be given any sort of secret tokens, or certificates...

This ended up being pretty tricky as I also did not want to force all zrok custom URLS into the SSO, as some services, like those that have rest API endpoints, once might not want to go behind an SSO wall. So I was able to create a service using a series of reverse proxies and a custom caddy auth module for adding SSO.

Please keep in mind everyones deployments are different. I am deploying everything via podman containers. For this post I am going to assume you have a working ZROK instance.

On my server, I have an nginx proxy that acts as a reverse proxy for all of my webapps (I have multiples running on this server). Normally I would just point my zrok instance which we will call:
https://zrok.myorg.com and my zrok controller is directed via https://api.zrok.myorg.com

So for my deployment, I have added ANOTHER reverese proxy in between my nginx-proxy and the web front ends of my zrok containers.

Using caddy, and caddy security I am building a custom container:

FROM caddy:2.8-builder AS builder
RUN xcaddy build \
    --with github.com/greenpau/caddy-security
FROM caddy:2.8
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

Once this is built, I need build my Caddyfile to do what I actually want it to do.

{
        order authenticate before respond
        # order authorize before reverse_proxy
        security {
                oauth identity provider generic {
                        realm generic
                        driver generic
                        client_id {Client-ID-From-ADFS-ADMIN}
                        client_secret {SECRET-From-ADFS-ADMIN}
                        scopes openid email profile
                        base_auth_url https://sso.myorg.com/adfs
                        metadata_url https://sso.myorg.com/adfs/.well-known/openid-configuration
                }

                authentication portal myportal {
                        crypto default token lifetime 3600
                        enable identity provider generic
                        cookie domain zrok.myorg.com
                        trust logout redirect uri domain {http.request.host} path /auth/oauth2/generic
                        ui {
                                custom js path /etc/caddy/custom.js
                                links {
                                        "My Identity" "/whoami" icon "las la-user"
                                        "app" "/app/" icon "las la-user"
                                }
                        }
                        transform user {
                                match realm generic
                                action add role authp/user
                        }
                }

                authorization policy mypolicy {
                        set auth url /auth
                        inject headers with claims
                        allow roles authp/admin authp/user
                }

        }
}
http:// {
    header {
        Access-Control-Allow-Origin *
        Access-Control-Allow-Credentials true
        Access-Control-Allow-Methods *
        Access-Control-Allow-Headers *
        defer
    }
    # Bind to the zrok share
    # bind {{ .ZrokBindAddress }}
    route /auth* {
        authenticate with myportal
    }
    authenticate with myportal

    # All other traffic goes to localhost:3000
    # authorize with mypolicy
    route /sso/* {
        authorize with mypolicy
        uri strip_prefix /sso
        reverse_proxy zrok-frontend:8080 {
            header_up Host {http.request.host}
            header_up X-Real-IP {http.request.header.x-forwarded-for}
        }
    }

    route /* {
        #request -X-Token*
        header -X-Token*
        # authorize with mypolicy
        #uri strip_prefix /app
        reverse_proxy zrok-frontend:8080 {
            header_down -X-**
            header_up -X-*
            header_up Host {http.request.host}
            header_up X-Real-IP {http.request.header.x-forwarded-for}
        }
    }
}
http://*.sso.zrok.myorg.com {
    header {
        Access-Control-Allow-Origin *
        Access-Control-Allow-Credentials true
        Access-Control-Allow-Methods *
        Access-Control-Allow-Headers *
        defer
    }
    # Bind to the zrok share
    # bind {{ .ZrokBindAddress }}
    route /auth* {
        authenticate with myportal
    }
    authenticate with myportal

    # All other traffic goes to localhost:3000
    # authorize with mypolicy
    route /* {
        authorize with mypolicy
        uri strip_prefix /sso
        reverse_proxy zrok-frontend:8080 {
            header_up Host {http.request.host}
            header_up X-Real-IP {http.request.header.x-forwarded-for}
        }
    }

}

NOTE
I have some stuff in here about "/sso/*" path... you can ignore this. I originally tried to use "custom-slug.zrok.myorg.com/sso/" to force the SSO, but by adding a path, this wreaked havoc on things like JS and CSS being supplied by apps. SO I changed this to include sso in the URL via custom-slug.sso.zrok.myorg.com

Also Caddy-Security is pretty solid, and supports multiple SSO integrations such as SAML, OIDC, and others...

One more addition... This tool has a landing page which I was not found of... so I inject some javascript to basically skip this page and load it via a custom.js file. So if someone finds themselves on the landing page, they should be automatically redirected through the SSO process.

window.location.replace('https://' + window.location.host + '/auth/oauth2/generic');

And then my podman run command looks something like this:

podman run -itd --name zrok-caddy \
    --network network-podman \
    -v $PWD/Caddyfile:/etc/caddy/Caddyfile:ro,z \
    -v $PWD/custom.js:/etc/caddy/custom.js:ro,z \
    localhost/caddy-security:latest

Keep in mind your nginx proxy (Or whatever proxy you use, will need to feed all ZROK requests to this container/service, which then proxies to the correct locations depending on the factors in the caddyfile.

SO... once this is working what is going on here? Basically I have two URLS that should both be working. If a user hits: https://custom-slug.zrok.myorg.com they should see their app, if they hit https://custom-slug.sso.zrok.myorg.com they should be forced through the SSO and then see the app! Neat! But there is an obvious problem. If both URLS work, then the app is not really protected... which is correct... so there is one more step!

Zrok share caddy backend to the rescue! (Yep more caddy!) We are going to use our zrok share command and force it to use a particular caddy setup. If you use Caddy frequently, you are 100% more likely to be better at configuring Caddy than me. But there are a few options here. For my caddy deployment, my OIDC integration gives me the user's email address, and caddy puts this in X-Token-User-Email, so I can determine if someone has authenticated or not. Using this, if this is empty, force the request through the SSO endpoint!

http:// {

    @authEmpty not header_regexp X-Token-User-Email ^(.*)
    @authSet header_regexp X-Token-User-Email ^(.*)

    # Bind to the zrok share
    bind {{ .ZrokBindAddress }}

    #https://8xb21u765ouo.zrok.myorg.com:443
    #{http.request.host.labels.3} - This is the zrok slug, it is 3 strings away from the .com
    redir @authEmpty https://{labels.3}.sso.zrok.myorg.com{uri}
    route /* {
        reverse_proxy @authSet localhost:631 {
            header_up Host localhost:631
            header_up X-Real-IP {http.request.header.x-forwarded-for}
        }

    }
}

In the above example, I am actually sharing my Mac's local CUPS web interface, and forcing users through my orgs SSO.

Some of you eagled eyes nerds (A term I use in the most affectionate way... as I am one!) might ask, but if all you have to do is set a host header, then what happens if I inject a value into X-Token-User-Email and try to trick the service into thinking I have authenticated? Well... the answer to that is in the caddy file that is running in the SSO Proxy:

            header_down -X-**
            header_up -X-*

Using these, I clear out all of the "X-*" headers if anyone makes a request via the non-sso endpoint. So the ONLY way to be considered "Authenticated" would be by going through the SSO endpoint in this case.

zrok share public --backend-mode caddy ./Caddyfile

You could take this a step further, lets say you wanted to restrict an app to certain SSO users... Can you easily grab the header value from sso -> caddy, and then direct the specific redirects based on these caddy variables

    @hasEmail header X-Token-User-Email John.Smith@myorg.com
    @notEmail not header X-Token-User-Email John.Smith@myorg.com

Additional Notes
For ADFS - I asked my ADFS admin to make sure *.sso.myorg.com was on the allowed domain list for the OIDC integration to ensure all of the random slugs could work with the integration

This is awesome stuff! Might take me a little while to absorb it all. :smiley:

This is all cool/fun stuff. Are you considering blogging about it? You could write also share it over on reddit or whatever other social you have. It sounds like you did some really cool work there, though! Nicely done!

Never been much of a blogger, happy to post on reddit though if that's something you guys think would be helpful.

That'd be really cool. Any publicity that's not from us is always appreciated and you're sure to get an upvote from me... :slight_smile: