Traefik forwardAuth explained

flow

From a picture above it should be pretty clear how it works.

In general, points are:

  1. before proceeding to our service, request is send to auth service
  2. depending on auth service response Traefik will either continue or block request

Before going deeper let's build and sandbox to see it in action

For demo purposes we are going to run everything in docker, so no need to install anything

First, let's start some service (aka the app we are going to protect)

docker run -it --rm --name=echo ealen/echo-server

Note: this one is an simple echo server, that will log and respond with details about incomming request, listening on port 80, we are not exposing it by intent

Second, for an auth service, actually, let's run the same image:

docker run -it --rm --name=auth ealen/echo-server

Note: the only difference here is the container name, we are going to use it in next step

Now it is turn for Traefik, first prepare configuration files:

static.yml

log:
  level: DEBUG
accessLog:
  format: common
providers:
  file:
    filename: /etc/traefik/dynamic.yml
entryPoints:
  http:
    address: :8080
    asDefault: true
experimental:
  plugins:
    google-oidc-auth-middleware:
      moduleName: github.com/andrewkroh/google-oidc-auth-middleware
      version: v0.1.0

dynamic.yml

http:
  middlewares:
    auth:
      forwardAuth:
        address: http://auth:80
  routers:
    echo:
      rule: Path(`/`)
      service: echo
      middlewares:
        - auth
  services:
    echo:
      loadBalancer:
        servers:
          - url: http://echo:80/

And finally run traefik:

docker run -it --rm --name=traefik --link=echo --link=auth -p 8080:8080 -v "$PWD/static.yml:/etc/traefik/static.yml" -v "$PWD/dynamic.yml:/etc/traefik/dynamic.yml" traefik:v3 --configfile=/etc/traefik/static.yml

Note: here we are linking echo and auth containers, so Traefik can reach them

Now, give it a try, and try call localhost:8080

curl localhost:8080

Note how both auth and echo containers receive the request

  1. Once received an request, Traefik did send request to our auth container
  2. It did respond with 200 OK and details about request
  3. Once received an 200 OK response from auth service, Traefik continued and proxied request to echo
  4. echo did respond with 200 OK and that response was proxied back to us

If you make few more calls, you should confirm, that Traefik does repeat that for all requests. This one may be important, if you are working with services under high load.

Also, if you have closer look you should notice that details about original request are passed as x-forwarded- headers, most of all, we probably interested in:

  • x-forwarded-host - which will contain hostname, and optionally port
  • x-forwarded-uri - will container path with query string

Cookies and authorization headers are passed as is, so you can access them as usual

Dummy auth service for Traefikd forward auth

So why not have fun, and build some dummy auth service

Here is an example of echo like service to start from

import { createServer } from 'http'

createServer((req, res) => {
  console.log({
    method: req.headers['x-forwarded-method'],
    host: req.headers['x-forwarded-host'],
    uri: req.headers['x-forwarded-uri'],
  })
  return res.writeHead(200, { 'Content-Type': 'text/plain' }).end('OK\n')
}).listen(3000, () => console.log('open http://localhost:3000'))

Start it like so:

docker run -it --rm --name=auth -v "$PWD/echo.js:/app/echo.js" -w /app node:23-alpine node echo.js

Note, because we are listening for port 3000, we should modify our dynamic.yml like so:

http:
  middlewares:
    auth:
      forwardAuth:
        address: http://auth:3000
        # 👆 we have changed port here, everything else is the same
  routers:
    echo:
      rule: Path(`/`)
      service: echo
      middlewares:
        - auth
  services:
    echo:
      loadBalancer:
        servers:
          - url: http://echo:80/

restart Traefik

docker run -it --rm --name=traefik --link=echo --link=auth -p 8080:8080 -v "$PWD/static.yml:/etc/traefik/static.yml" -v "$PWD/dynamic.yml:/etc/traefik/dynamic.yml" traefik:v3 --configfile=/etc/traefik/static.yml

make curl call:

curl -X POST 'http://localhost:8080/?foo=bar'

note how you are receing response from echo server, while in auth service we are logging following:

{ method: 'POST', host: 'localhost:8080', uri: '/?foo=bar' }

Here is our dummy part, let's check user query string parameter, if it is present - we are treating request as valid, and responding with 200 OK, if not, respond with 401

import { createServer } from 'http'

createServer((req, res) => {
  const user = new URL(req.headers['x-forwarded-uri'], 'http://localhost').searchParams.get('user')
  console.log({
    method: req.headers['x-forwarded-method'],
    host: req.headers['x-forwarded-host'],
    uri: req.headers['x-forwarded-uri'],
    user: user,
  })
  if (!user) {
    return res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Who are you?\n')
  }
  return res.writeHead(200, { 'Content-Type': 'text/plain' }).end('OK\n')
}).listen(3000, () => console.log('open http://localhost:3000'))

Now attemt to run:

curl -X POST 'http://localhost:8080/?foo=bar'

will fail with 401 Unauthorized error and response Who are you?

and request like this:

curl -X POST 'http://localhost:8080/?user=alice'

will succeed.

Technically, that all, and yes, it is so simple.

Buf ofcourse noone will use such implementation, at the very end we need cookies to handle the state, here is another minimalistic example:

import { createServer } from 'http'

createServer((req, res) => {
  const url = new URL(req.headers['x-forwarded-proto'] + '://' + req.headers['x-forwarded-host'] + req.headers['x-forwarded-uri'])

  // let's pretend such url is called on OAuth flow completion callback, from here we want to set an cookie and redirect user to home page
  if (url.pathname === '/demo-as-if-it-was-callback-from-oauth' && url.searchParams.get('user')) {
    return res.writeHead(302, { 'Set-Cookie': `user=${url.searchParams.get('user')}; HttpOnly;`, Location: req.headers['x-forwarded-proto'] + '://' + req.headers['x-forwarded-host'] + '/' }).end()
  }

  const user = req.headers.cookie?.match(/user=(\w+)/)?.pop()
  console.log({
    url: url.toString(),
    user: user,
  })
  if (!user) {
    return res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Who are you?\n')
  }
  return res.writeHead(200, { 'Content-Type': 'text/plain' }).end('OK\n')
}).listen(3000, () => console.log('open http://localhost:3000'))

keep an eye on how we are handing very special endpoint

let's check if everything still works

curl 'http://localhost:8080/?user=alice'

as expected gives us "Who are you?" response

Now, let's pretend we have logged in:

curl -s -i 'http://localhost:8080/demo-as-if-it-was-callback-from-oauth?user=alice'

we will receive 404 - which is indeed expected, we need to modify our router a little bit

http:
  middlewares:
    auth:
      forwardAuth:
        address: http://auth:3000
  routers:
    echo:
      rule: Path(`/`) || Path(`/demo-as-if-it-was-callback-from-oauth`)
      # 👆 we have added one more path to this route, later we will see other options
      service: echo
      middlewares:
        - auth
  services:
    echo:
      loadBalancer:
        servers:
          - url: http://echo:80/

now our request gives us desired response:

HTTP/1.1 302 Found
Date: Fri, 14 Mar 2025 08:03:44 GMT
Location: http://localhost:8080/
Set-Cookie: user=alice; HttpOnly;
Content-Length: 0

and as you may guess next request from browser will be:

curl 'http://localhost:8080/?foo=bar' -H 'Cookie: user=alice'

which will work

at this point you should be able to do the same in browser by opening this urls

Note: in real Traefik setups your routes will be catching hostanemes, aka:

http:
  routers:
    echo:
      rule: Host(`echo.mac-blog.org.ua`)

Because of that, they will catch all requests and there will be no need for dedicated rule to catch this callback url

Traefik OpenId Connect forwardAuth

Of course, no one will rely on such dummy auth service.

To bypass all examples in the middle let's just directly into oidc

It is required to have some understanding of how it works, previously there were some notes like this:

But never the less here is what we are going to build:

Before jumping into complicated suff let's play with Github, it has minimalistic OAuth implementation

Traefik forwardAuth Github

Navigate to github.com/settings/developers and create new app with following params:

Note: because literally all requests are catched and send to our service, we may use anything we want as callback url, for demo purposes I have choosen /can-be-anything-we-want to highlight that

Once created, press "Generate a new client secret" button

And keep note on both client_id and client_secret

in my case values are:

  • client_id: Ov23liWuoZki5P2ubkFy
  • client_secret: b23abc4e88d9e3973d5ac199dc4844c3b9f675ce

How it will work:

on auth side, we are going to check github cookie

if request has it and it is "valid" then respond with 200 ok otherwise we need to block request

to keep everything secure, we are going to use session cookies

note: there are two use cases - web requests and api requests we should behave differently for them

here is naive pseudo code for auth middleware:

async function handler(req, res) {
  if (req.headers['x-forwarded-uri'].startsWith('/can-be-anything-we-want')) {
    const code = extractCodeFromQueryString(req.headers['x-forwarded-uri'])
    const token = await exchangeCodeToToken(code)
    return res.redirect('/', { 'set-cookie': `github=${token}` })
  }

  const tokenFromCookie = extractGithubCookieFrom(req)
  const valid = await callUserInfoEndpointWith(tokenFromCookie)
  if (valid) {
    return res.sendStatus(200)
  }
  if (isApiRequest(req)) {
    return res.sendStatus(401)
  } else {
    return res.redirect(buildGithubLoginUrl())
  }
}

it is kind of similar to previous example with cookies

here is what i have ended up with

import { createServer } from 'http'

createServer(async (req, res) => {
  const url = new URL(req.headers['x-forwarded-proto'] + '://' + req.headers['x-forwarded-host'] + req.headers['x-forwarded-uri'])
  console.log('processing', url.toString())

  // this one is where user redirected after github login
  if (url.pathname === '/can-be-anything-we-want' && url.searchParams.get('code')) {
    const client_id = 'Ov23liWuoZki5P2ubkFy'
    const client_secret = 'b23abc4e88d9e3973d5ac199dc4844c3b9f675ce'
    const code = url.searchParams.get('code')
    console.log(`callback, exchanging code ${code} for token`)
    const { access_token } = await fetch('https://github.com/login/oauth/access_token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({ client_id, client_secret, code }),
    }).then(r => r.json())
    console.log(`got token ${access_token}, set it as github cookie and redirect user to home page`)
    return res.writeHead(302, { 'Set-Cookie': `github=${access_token}; HttpOnly;`, Location: req.headers['x-forwarded-proto'] + '://' + req.headers['x-forwarded-host'] + '/' }).end()
  }

  const github = req.headers.cookie?.match(/github=(\w+)/)?.pop()
  if (!github) {
    console.log('not authenticated, redirect user to github login')
    return res.writeHead(302, { Location: getLoginUrl() }).end()
  }

  console.log('authenticated, lets check email')
  var { email } = await fetch('https://api.github.com/user', { headers: { Authorization: `token ${github}` } }).then(r => r.json())
  // if (email !== '[email protected]') {
  //   console.log(`unexpected email ${email}, redirect user to github login`)
  //   return res.writeHead(302, { Location: getLoginUrl() }).end()
  // }
  console.log(`email: ${email}`)

  return res.writeHead(200, { 'Content-Type': 'text/plain' }).end('OK\n')
}).listen(3000, () => console.log('open http://localhost:3000'))

function getLoginUrl() {
  const url = new URL('https://github.com/login/oauth/authorize')
  url.searchParams.set('client_id', 'Ov23liWuoZki5P2ubkFy')
  url.searchParams.set('redirect_uri', 'http://localhost:8080/can-be-anything-we-want')
  url.searchParams.set('scope', ['user'].join(' '))
  url.searchParams.set('state', crypto.randomUUID())
  url.searchParams.set('allow_signup', 'false')
  return url.toString()
}

and for it to work, we need to once again modify our route

http:
  # ..
  routers:
    echo:
      rule:
        Path(`/`) || Path(`/can-be-anything-we-want`) # 👈 path
        # ...

with this setup - it will work in browser:

  • when you open http://localhost:8080
  • you will be redirected to https://github.com/login/oauth/authorize?client_id=Ov23liWuoZki5P2ubkFy&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcan-be-anything-we-want&scope=user&state=9f8d6d03-b629-45d5-9bdc-72734a1aaa88&allow_signup=false
  • once logged in, we will be redirected back to http://localhost:8080/can-be-anything-we-want?code=4aa78194e6eb9314ca9a&state=5e8302fd-22a6-44cf-bd27-e08f26bb4f89
  • after exchanging code to token, and setting it as an github cookie we will redirect user back to home page, so browser will be landed to http://localhost:8080
  • once http://localhost:8080 is requested once again, our middleware does recognize the cookie and checks its email, returns 200 OK so Traefik proceeds and proxies our request to echo service

Note, that on each subsequent request our auth service is still called and still performs checks which both good and bad, depending on what we are protecting

Just from curiosity, lets add some caching and encryption on top

Here is the idea:

  • once authenticated, we will store authentication details in memory, tracking last time we did check them
  • for cookie, instead of saving github token we will save our own encrypted session token
  • for subsequent requests we will check our in memory storage instead of calling github api each time

Here is example implementation

import { createServer } from 'http'
import { createHash, createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'crypto'

const secret = 'SecretPhraseToEncryptDecryptCookie'
const memory = new Map()
const client_id = 'Ov23liWuoZki5P2ubkFy'
const client_secret = 'b23abc4e88d9e3973d5ac199dc4844c3b9f675ce'
const redirect_uri = 'http://localhost:8080/can-be-anything-we-want'

createServer(async (req, res) => {
  const url = new URL(req.headers['x-forwarded-proto'] + '://' + req.headers['x-forwarded-host'] + req.headers['x-forwarded-uri'])
  console.log('processing', url.toString())

  // this one is where user redirected after github login
  if (url.pathname === '/can-be-anything-we-want' && url.searchParams.get('code')) {
    const code = url.searchParams.get('code')
    console.log(`callback, exchanging code ${code} for token`)
    const { access_token } = await fetch('https://github.com/login/oauth/access_token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({ client_id, client_secret, code }),
    }).then(r => r.json())
    console.log(`got an access token, retrieving user details`)
    const info = await getUserDetails(access_token)
    console.log(info)
    if (!info) {
      console.log('something wrong, can not retrieve user info, responding with 401')
      return res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Who are you?\n')
    }
    memory.set(info.id, info)
    const session = encrypt(info.id, secret)
    console.log('session', session)
    console.log(`redirecting user back to home page with session cookie`)
    return res.writeHead(302, { 'Set-Cookie': `session=${session}; HttpOnly;`, Location: req.headers['x-forwarded-proto'] + '://' + req.headers['x-forwarded-host'] + '/' }).end()
  }

  const session = req.headers.cookie?.match(/session=([^(;|$)]+)/)?.pop()
  if (!session) {
    console.log('not authenticated, redirect user to github login')
    return res.writeHead(302, { Location: getLoginUrl() }).end()
  }
  console.log('incomming session', session)
  const id = decrypt(session, secret)
  if (!id) {
    console.log('invalid session, can not decrypt, redirect user to github login')
    return res.writeHead(302, { Location: getLoginUrl() }).end()
  }
  const info = memory.get(Number(id))
  if (!info) {
    console.log('invalid session, no user info found, redirect user to github login')
    return res.writeHead(302, { Location: getLoginUrl() }).end()
  }
  const timePassedSinceLastCheck = Date.now() - info.at
  if (timePassedSinceLastCheck > 60 * 60 * 1000) {
    console.log('token was checked more than 1 hour ago, we should recheck it once again')
    const info = await getUserDetails(info.token)
    if (!info) {
      console.log('failed to recheck token, redirect user to github login')
      return res.writeHead(302, { Location: getLoginUrl() }).end()
    }
    console.log(info)
    memory.set(info.id, info)
  }

  console.log(info)
  console.log('responding with 200 OK to Traefik, passing additonal headers')
  return res
    .writeHead(200, {
      'Content-Type': 'text/plain',
      'X-User-Id': info.id,
      'X-User-Login': info.login,
      'X-User-Name': info.name,
      'X-User-Avatar': info.avatar_url,
    })
    .end('OK\n')
}).listen(3000, () => console.log('open http://localhost:3000'))

function getLoginUrl() {
  const url = new URL('https://github.com/login/oauth/authorize')
  url.searchParams.set('client_id', client_id)
  url.searchParams.set('redirect_uri', redirect_uri)
  url.searchParams.set('scope', ['user'].join(' '))
  url.searchParams.set('state', randomUUID())
  url.searchParams.set('allow_signup', 'false')
  return url.toString()
}

async function getUserDetails(token) {
  // { "id": 88868, "login": "mac2000", "name": "Alexandr Marchenko", "email": "[email protected]", "avatar_url": "https://avatars.githubusercontent.com/u/88868?v=4" }
  const { id, login, name, avatar_url } = await fetch('https://api.github.com/user', { headers: { Authorization: `Bearer ${token}` } })
    .then(r => r.json())
    .catch(error => {
      console.warn(`error while retrieving user info ${error.message}`)
      return {}
    })
  if (!id) {
    return null
  }
  return { id, login, name, avatar_url, token, at: Date.now() }
}

function encrypt(text, secret) {
  if (typeof text === 'number') {
    text = text.toString()
  }
  const key = createHash('sha256').update(secret).digest('hex').substring(0, 32)
  const iv = randomBytes(16)
  const cipher = createCipheriv('aes-256-ctr', key, iv)
  const bytes = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
  return iv.toString('base64') + ':' + bytes.toString('base64')
}

function decrypt(input, secret) {
  try {
    const key = createHash('sha256').update(secret).digest('hex').substring(0, 32)
    const [iv, encrypted] = input.split(':')
    const decipher = createDecipheriv('aes-256-ctr', key, Buffer.from(iv, 'base64'))
    const bytes = Buffer.concat([decipher.update(encrypted, 'base64'), decipher.final()])
    return bytes.toString('utf-8')
  } catch (error) {
    console.warn(`error while decrypting given input ${error.message}`)
    return null
  }
}

Notes:

  • we are storin in memory map where key is user id, and value are user details
  • for cookie we are storing encrypted user id
  • every hour, just in case, we are revalidating token

Also, for demo purposes, we are passing headers like X-User-Login to our echo service

for this to work, add following changes to dynamic.yml

http:
  middlewares:
    auth:
      forwardAuth:
        address: http://auth:3000
        authResponseHeaders:
          - X-User-Id
          - X-User-Login
          - X-User-Name
          - X-User-Avatar
# ...

so we have almost ready to use example for github, add few more checks here and there and you are reday to go

but initially the goal was to cover oidc forward auth so

Traefik Google auth

Now, when we have played with simplified Github example, it should be much easier to do something similar with Google

By intent I will start with nodejs example, so it can be run as single script without the need to compile anything, and later on, if everything will be working fine, will add Golang example as well

For Google we gonna need Google Cloud Project, configured OAuth contest screen and create an client - I expect this one should not be a problem, otherwise everthing below will be an dark magic

here is what i have ended up with

import { createServer } from 'http'
import { createHash, createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'crypto'

const secret = 'SecretPhraseToEncryptDecryptCookie'
const memory = new Map()
const client_id = '869226817339-0hlu97qq43ia4l24eugbl3q570v373m7.apps.googleusercontent.com'
const client_secret = 'GOCSPX-xxxxxx-kJsu'
const redirect_uri = 'http://localhost:8080/callback'

createServer(async (req, res) => {
  const url = new URL(req.headers['x-forwarded-proto'] + '://' + req.headers['x-forwarded-host'] + req.headers['x-forwarded-uri'])
  console.log('processing', url.toString())

  // this one is where user redirected after google login
  if (url.pathname === '/callback' && url.searchParams.get('code')) {
    const code = url.searchParams.get('code')
    console.log(`callback, exchanging code ${code} for token`)
    const { access_token } = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code,
        client_id,
        client_secret,
        redirect_uri,
      }),
    }).then(r => r.json())
    console.log(`got an access token, retrieving user details`)
    const info = await getUserDetails(access_token)
    console.log(info)
    if (!info) {
      console.log('something wrong, can not retrieve user info, responding with 401')
      return res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Who are you?\n')
    }
    memory.set(info.id, info)
    console.log('memory keys', Array.from(memory.keys()))
    console.log('id', info.id)
    const session = encrypt(info.id, secret)
    console.log('session', session)
    console.log(`redirecting user back to home page with session cookie`)
    return res.writeHead(302, { 'Set-Cookie': `session=${session}; HttpOnly;`, Location: req.headers['x-forwarded-proto'] + '://' + req.headers['x-forwarded-host'] + '/' }).end()
  }

  const session = req.headers.cookie?.match(/session=([^(;|$)]+)/)?.pop()
  if (!session) {
    console.log('not authenticated, redirect user to google login')
    return res.writeHead(302, { Location: getLoginUrl() }).end()
  }
  console.log('incomming session', session)
  const id = decrypt(session, secret)
  console.log('decrypted id', id)
  if (!id) {
    console.log('invalid session, can not decrypt, redirect user to google login')
    return res.writeHead(302, { Location: getLoginUrl() }).end()
  }
  console.log('memory keys', Array.from(memory.keys()))
  const info = memory.get(id)
  if (!info) {
    console.log('invalid session, no user info found, redirect user to google login')
    return res.writeHead(302, { Location: getLoginUrl() }).end()
  }
  const timePassedSinceLastCheck = Date.now() - info.at
  if (timePassedSinceLastCheck > 60 * 60 * 1000) {
    console.log('token was checked more than 1 hour ago, we should recheck it once again')
    const info = await getUserDetails(info.token)
    if (!info) {
      console.log('failed to recheck token, redirect user to google login')
      return res.writeHead(302, { Location: getLoginUrl() }).end()
    }
    console.log(info)
    memory.set(info.id, info)
  }

  console.log(info)
  console.log('responding with 200 OK to Traefik, passing additonal headers')
  return res
    .writeHead(200, {
      'Content-Type': 'text/plain',
      'X-User-Id': info.id,
      'X-User-Name': info.name,
      'X-User-Picture': info.picture,
    })
    .end('OK\n')
}).listen(3000, () => console.log('open http://localhost:3000'))

function getLoginUrl() {
  const url = new URL('https://accounts.google.com/o/oauth2/v2/auth')
  url.searchParams.set('response_type', 'code')
  url.searchParams.set('client_id', client_id)
  url.searchParams.set('redirect_uri', redirect_uri)
  url.searchParams.set('scope', ['profile'].join(' '))
  return url.toString()
}

async function getUserDetails(token) {
  // { "sub": "114259064191790696813", "name": "Alexandr Marchenko", "given_name": "Alexandr", "family_name": "Marchenko", "picture": "https://lh3.googleusercontent.com/a/ACg8ocLCg6rfbaPrEuotvuKegwXSu-TAXlT20AfJNJuoj5cZx604sxca\u003ds96-c" }
  const { sub, name, picture } = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { headers: { Authorization: `Bearer ${token}` } })
    .then(r => r.json())
    .catch(error => {
      console.warn(`error while retrieving user info ${error.message}`)
      return {}
    })
  if (!sub) {
    return null
  }
  return { id: sub, name, picture, token, at: Date.now() }
}

function encrypt(text, secret) {
  const key = createHash('sha256').update(secret).digest('hex').substring(0, 32)
  const iv = randomBytes(16)
  const cipher = createCipheriv('aes-256-ctr', key, iv)
  const bytes = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
  return iv.toString('base64') + ':' + bytes.toString('base64')
}

function decrypt(input, secret) {
  try {
    const key = createHash('sha256').update(secret).digest('hex').substring(0, 32)
    const [iv, encrypted] = input.split(':')
    const decipher = createDecipheriv('aes-256-ctr', key, Buffer.from(iv, 'base64'))
    const bytes = Buffer.concat([decipher.update(encrypted, 'base64'), decipher.final()])
    return bytes.toString('utf-8')
  } catch (error) {
    console.warn(`error while decrypting given input ${error.message}`)
    return null
  }
}

note: in real life we want to utilize refresh tokens and do some fancy stuff, but in my case it is expected to have short lived session cookies - aka it will last only whily you are using the app, and once you close the browser - you are done, next time - new login

So the final setup is:

static.yml

log:
  level: DEBUG
accessLog:
  format: common
providers:
  file:
    filename: /etc/traefik/dynamic.yml
entryPoints:
  http:
    address: :8080
    asDefault: true

dynamic.yml

http:
  middlewares:
    auth:
      forwardAuth:
        address: http://auth:3000
        authResponseHeaders:
          - X-User-Id
          - X-User-Name
          - X-User-Picture
  routers:
    echo:
      rule: Path(`/`) || Path(`/callback`)
      service: echo
      middlewares:
        - auth
  services:
    echo:
      loadBalancer:
        servers:
          - url: http://echo:80/

echo

docker run -it --rm --name=echo ealen/echo-server

auth

docker run -it --rm --name=auth -v "$PWD/google.js:/app/google.js" -w /app node:23-alpine node google.js

traefik

docker run -it --rm --name=traefik --link=echo --link=auth -p 8080:8080 -v "$PWD/static.yml:/etc/traefik/static.yml" -v "$PWD/dynamic.yml:/etc/traefik/dynamic.yml" traefik:v3 --configfile=/etc/traefik/static.yml

OAuth2-proxy Traefik forwardAuth

Having all that in place, should allow us to not reinwent the wheel and configure something existant, for example oauth2-proxy

Technically it does exactly the same we were writting before

Here is small refresher - OAuth2 Proxy in Kubernetes

docker run -it --rm --name=auth -p 4180:4180 quay.io/oauth2-proxy/oauth2-proxy \
  --provider=google \
  --client-id=869226817339-c14i5aj3mq1rkht381u9c15lujm5810b.apps.googleusercontent.com \
  --client-secret=GOCSPX-xxxxxx \
  --cookie-secret=wsFX9kYoi00jYjA5m_tv6hzZvwk8OvHmtW9pCiQkpXA= \
  --email-domain=* \
  --upstream=file:///dev/null \
  --redirect-url=http://localhost:4180/oauth2/callback \
  --http-address=0.0.0.0:4180 \
  --cookie-secure=false

Notes:

  • to create cookie seecret use snippets from docs - under the hood 32 random bytes
  • while in localhost - we disabling secure flag for cookies
  • you should be able to open http://localhost:4180 and login via Google
  • after login, you will be landed on 404 page not found - which is fine for now

There is an documentation and examples of how to integrate Traefik with OAuth2 here

After reading docs many times I have ended up with following setup:

echo

docker run -it --rm --name=echo ealen/echo-server

auth

docker run -it --rm --name=auth quay.io/oauth2-proxy/oauth2-proxy \
  --provider=google \
  --client-id=869226817339-c14i5aj3mq1rkht381u9c15lujm5810b.apps.googleusercontent.com \
  --client-secret=GOCSPX-xxxxxx \
  --cookie-secret=wsFX9kYoi00jYjA5m_tv6hzZvwk8OvHmtW9pCiQkpXA= \
  --email-domain=* \
  --http-address=0.0.0.0:4180 \
  --cookie-secure=false \
  --cookie-name=auth \
  --set-xauthrequest=true

Notes:

  • --cookie-secure=false - for now, while playing without https
  • --cookie-name=auth - just to control and see it
  • --skip-jwt-bearer-tokens=true - for api requests, should allow authorization bearer headers
  • --set-xauthrequest=true - will pass X-Auth-Request-User and X-Auth-Request-Email headers to response
  • --skip-provider-button=true - nice to have but does not work as expected

static.yml

log:
  level: DEBUG
accessLog:
  format: common
providers:
  file:
    filename: /etc/traefik/dynamic.yml
entryPoints:
  http:
    address: :8080
    asDefault: true

dynamic.yml

http:
  middlewares:
    auth:
      forwardAuth:
        address: http://auth:4180/oauth2/auth
        authResponseHeaders:
          - X-Auth-Request-User
          - X-Auth-Request-Email
          - X-Forwarded-User
    errors:
      errors:
        status:
          - '401-403'
        service: auth
        query: '/oauth2/sign_in?rd={url}'
  routers:
    echo:
      rule: PathPrefix(`/`)
      service: echo
      middlewares:
        - errors
        - auth
    auth:
      rule: PathPrefix(`/oauth2/`)
      service: auth
  services:
    echo:
      loadBalancer:
        servers:
          - url: http://echo:80/
    auth:
      loadBalancer:
        servers:
          - url: http://auth:4180/

traefik

docker run -it --rm --name=traefik --link=echo --link=auth -p 8080:8080 -v "$PWD/static.yml:/etc/traefik/static.yml" -v "$PWD/dynamic.yml:/etc/traefik/dynamic.yml" traefik:v3 --configfile=/etc/traefik/static.yml

With this setup you will get kind of similar behaviour to what we have written

But there is even more

Only after all this, have discovered Traefik plugins

So the plugins in Traefik are kind of JavaScript for browsers, Go code is being injected and interpretated

There is already one created Google OIDC Auth

And indeed setup is as easy as:

echo

docker run -it --rm --name=echo ealen/echo-server

auth

not needed

static.yml

log:
  level: DEBUG
accessLog:
  format: common
providers:
  file:
    filename: /etc/traefik/dynamic.yml
entryPoints:
  http:
    address: :8080
    asDefault: true
experimental:
  plugins:
    google-oidc-auth-middleware:
      moduleName: github.com/andrewkroh/google-oidc-auth-middleware
      version: v0.1.0

dynamic.yml

http:
  middlewares:
    auth:
      plugin:
        google-oidc-auth-middleware:
          authorized:
            emails:
              - [email protected]
          cookie:
            secret: wsFX9kYoi00jYjA5m_tv6hzZvwk8OvHmtW9pCiQkpXA=
            insecure: true
          oidc:
            clientID: 869226817339-c14i5aj3mq1rkht381u9c15lujm5810b.apps.googleusercontent.com
            clientSecret: GOCSPX-xxxxxx
  routers:
    echo:
      rule: PathPrefix(`/`)
      service: echo
      middlewares:
        - auth
  services:
    echo:
      loadBalancer:
        servers:
          - url: http://echo:80/

traefik

docker run -it --rm --name=traefik --link=echo -p 8080:8080 -v "$PWD/static.yml:/etc/traefik/static.yml" -v "$PWD/dynamic.yml:/etc/traefik/dynamic.yml" traefik:v3 --configfile=/etc/traefik/static.yml

And here is one more:

experimental:
  plugins:
    traefikoidc:
      moduleName: github.com/lukaszraczylo/traefikoidc
      version: v0.5.34
http:
  middlewares:
    auth:
      plugin:
        traefikoidc:
          sessionEncryptionKey: e7a4fdbd-baa7-4bf3-8ae6-f4734f0471fd
          providerURL: https://accounts.google.com
          clientID: 869226817339-c14i5aj3mq1rkht381u9c15lujm5810b.apps.googleusercontent.com
          clientSecret: GOCSPX-xxxxxx
          callbackURL: /oauth2/callback
          forceHTTPS: false