Skip to content

Instantly share code, notes, and snippets.

@mac2000
Created November 22, 2021 07:59
Show Gist options
  • Save mac2000/5aa4448b89fa74c390da9501728f2e54 to your computer and use it in GitHub Desktop.
Save mac2000/5aa4448b89fa74c390da9501728f2e54 to your computer and use it in GitHub Desktop.
kubernetes auth-proxy
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
func main() {
clientId := os.Getenv("AAD_CLIEN_ID")
clientSecret := os.Getenv("AAD_CLIEN_SECRET")
tenantId := os.Getenv("AAD_TENANT_ID")
callbackUrl := os.Getenv("AAD_CALLBACK_URL")
cookieDomain := os.Getenv("AAD_COOKIE_DOMAIN")
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, fmt.Sprintf("https://sts.windows.net/%s/", tenantId))
if err != nil {
log.Fatal(err)
}
verifier := provider.Verifier(&oidc.Config{ClientID: clientId})
config := oauth2.Config{
ClientID: clientId,
ClientSecret: clientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: callbackUrl,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("id_token")
if err != nil {
log.Println("home handler, unable to retrieve id_token cookie: " + err.Error())
// TODO: its not an error - render home page html for anonymous user
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
idToken, err := verifier.Verify(ctx, cookie.Value)
if err != nil {
log.Println("home handler, unable to verify id_token: " + err.Error())
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// TODO: render html page
user := User{}
idToken.Claims(&user)
data, err := json.Marshal(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(data)
})
http.HandleFunc("/check", func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("id_token")
if err != nil {
log.Println("check handler, unable to get id_token cookis: " + err.Error())
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
idToken, err := verifier.Verify(ctx, cookie.Value)
if err != nil {
log.Println("check handler, unable to verify id token: " + err.Error())
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
user := User{}
idToken.Claims(&user)
log.Println("check handler, success: " + user.Email)
fmt.Fprintf(w, "OK")
})
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
rd := r.URL.Query().Get("rd")
if rd == "" {
rd = "/"
}
state, err := randString(16)
if err != nil {
log.Println("login handler, unable create state: " + err.Error())
// TODO: user facing page, need html representation
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
nonce, err := randString(16)
if err != nil {
log.Println("login handler, unable create nonce: " + err.Error())
// TODO: user facing page, need html representation
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
ttl := int((5 * time.Minute).Seconds())
setCallbackCookie(w, r, "rd", rd, cookieDomain, ttl)
setCallbackCookie(w, r, "state", state, cookieDomain, ttl)
setCallbackCookie(w, r, "nonce", nonce, cookieDomain, ttl)
log.Println("login handler, rd: " + rd)
url := config.AuthCodeURL(state, oidc.Nonce(nonce))
log.Println("login handler, redirecting to: " + url)
http.Redirect(w, r, url, http.StatusFound)
})
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie("state")
if err != nil {
log.Println("callback handler, unable to get state from cookie: " + err.Error())
// TODO: user facing page, need html representation
http.Error(w, "state not found", http.StatusBadRequest)
return
}
if r.URL.Query().Get("state") != state.Value {
log.Println("callback handler, state from cookie and identity provider did not match")
// TODO: user facing page, need html representation
http.Error(w, "state did not match", http.StatusBadRequest)
return
}
oauth2Token, err := config.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
log.Println("callback handler, unable to exchange code for access token: " + err.Error())
// TODO: user facing page, need html representation
http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
log.Println("callback handler, unable to get id_token from oauth2 token")
// TODO: user facing page, need html representation
http.Error(w, "No id_token field in oauth2 token.", http.StatusInternalServerError)
return
}
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
log.Println("callback handler, unable to verify id_token: " + err.Error())
// TODO: user facing page, need html representation
http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError)
return
}
nonce, err := r.Cookie("nonce")
if err != nil {
log.Println("callback handler, unable get nonce from cookie: " + err.Error())
// TODO: user facing page, need html representation
http.Error(w, "nonce not found", http.StatusBadRequest)
return
}
if idToken.Nonce != nonce.Value {
log.Println("callback handler, nonce in cookie and id_token did not match")
// TODO: user facing page, need html representation
http.Error(w, "nonce did not match", http.StatusBadRequest)
return
}
user := User{}
idToken.Claims(&user)
setCallbackCookie(w, r, "id_token", rawIDToken, cookieDomain, int(time.Until(oauth2Token.Expiry).Seconds()))
log.Println("callback handler, successfully logged in " + user.Email)
rd, err := r.Cookie("rd")
if err != nil || rd.Value == "" {
rd.Value = "/"
}
http.Redirect(w, r, rd.Value, http.StatusFound)
})
http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
setCallbackCookie(w, r, "id_token", "", cookieDomain, 0)
rd := r.URL.Query().Get("rd")
if rd == "" {
rd = "/"
}
http.Redirect(w, r, rd, http.StatusFound)
})
log.Println("listening on http://0.0.0.0:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
type User struct {
// Id string `json:"sub"`
Name string `json:"name"`
Email string `json:"unique_name"` // unique_name, upn
Roles []string `json:"roles`
}
func randString(nByte int) (string, error) {
b := make([]byte, nByte)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value, domain string, ttl int) {
c := &http.Cookie{
Name: name,
Value: value,
Domain: domain,
MaxAge: ttl,
Secure: r.TLS != nil,
HttpOnly: true,
}
http.SetCookie(w, c)
}
server {
location = /check {
# if there is no "authorization" cookie we pretend that user is not logged in
if ($cookie_authorization = "") {
return 401;
}
# demo for authorization header
# if ($http_authorization != "Bearer 123") {
# return 401;
# }
# if we land here then "authorization" cookie is present
add_header Content-Type text/plain;
return 200 "OK";
}
location = /login {
add_header Set-Cookie "authorization=123;Domain=.cub.marchenko.net.ua;Path=/;Max-Age=100000";
return 302 http://app1.cub.marchenko.net.ua;
# https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#parameters
# note that we are redirecting back to auth
# $arg_rd - stands for "rd" query string parameter
# do not forget to replace "client_id"
# we are cheating with "state" to pass "rd" query string back to callback
# return 302 https://github.com/login/oauth/authorize?client_id=********************&redirect_uri=http://auth.cub.marchenko.net.ua/callback&state=$arg_rd;
}
# because of "redirect_uri" after successfull login we will be redirected here
# and because we have passed "rd" query string in "redirect_uri" we could use it here
location = /callback {
# note domain - we need that so cookie will be available on all subdomain
add_header Set-Cookie "authorization=123;Domain=.cub.marchenko.net.ua;Path=/;Max-Age=100000";
# $arg_state - stands for "state" query string parameter
# did not work, variable is encoded and nginx redirect us to root of auth app
# return 302 $agr_state;
return 302 http://app1.cub.marchenko.net.ua;
}
location = /logout {
# remove cookie
add_header Set-Cookie "authorization=;Domain=.cub.marchenko.net.ua;Path=/;Max-Age=0";
# idea was to redirect back to app1, which will see that we are anonymous and send us back to login
# but it did not worked out, github remembers our decision and automatically logs us back
# return 302 http://app1.cub.marchenko.net.ua;
return 302 http://auth.cub.marchenko.net.ua;
}
location / {
add_header Content-Type text/plain;
return 200 "Auth Home Page\n";
}
}
const http = require('http')
const https = require('https')
const crypto = require('crypto')
const assert = require('assert')
assert.ok(process.env.CLIENT_ID, 'CLIENT_ID environment variable is missing')
assert.ok(process.env.CLIENT_SECRET, 'CLIENT_SECRET environment variable is missing')
process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || crypto.randomBytes(16).toString('hex')
// process.env.COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || null
process.env.COOKIE_MAX_AGE = process.env.COOKIE_MAX_AGE || 60 * 60
process.env.COOKIE_NAME = process.env.COOKIE_NAME || 'oauth3-proxy'
process.env.SCOPE = process.env.SCOPE || 'read:user,user:email'
process.env.PORT = process.env.PORT || 3000
process.env.REDIRECT_URL = process.env.REDIRECT_URL || `http://localhost:${process.env.PORT}/callback`
function exchange (code) {
const data = JSON.stringify({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
code: code
})
console.log(
`curl -s -i -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' https://github.com/login/oauth/access_token -d '${data}'`
)
return new Promise((resolve, reject) => {
const url = 'https://github.com/login/oauth/access_token'
const method = 'POST'
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json'
}
const req = https.request(url, { headers, method }, (res) => {
console.log(`${res.statusCode} ${res.statusMessage}`)
let data = ''
res.on('data', (chunk) => (data += chunk))
res.on('end', () => {
console.log(data)
try {
const json = JSON.parse(data)
if (res.statusCode < 400) {
resolve(json.access_token)
} else {
reject(json)
}
} catch (error) {
reject(error)
}
})
})
req.on('error', (error) => {
console.error(error)
reject(error)
})
req.write(
JSON.stringify({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
code: code
})
)
req.end()
})
}
function encrypt (text) {
const key = crypto.createHash('sha256').update(process.env.ENCRYPTION_KEY).digest('hex').substring(0, 32)
const iv = crypto.randomBytes(16) // for AES this is always 16
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key), iv)
const encrypted = Buffer.concat([cipher.update(text), cipher.final()])
return iv.toString('hex') + ':' + encrypted.toString('hex')
}
function decrypt (text) {
const key = crypto.createHash('sha256').update(process.env.ENCRYPTION_KEY).digest('hex').substring(0, 32)
const iv = text.split(':').shift()
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(key), Buffer.from(iv, 'hex'))
const decrypted = decipher.update(Buffer.from(text.substring(iv.length + 1), 'hex'))
return Buffer.concat([decrypted, decipher.final()]).toString()
}
http.ServerResponse.prototype.send = function (status, data) {
this.writeHead(status, { 'Content-Type': 'text/html' })
this.write(data)
this.write('\n')
this.end()
}
http.ServerResponse.prototype.redirect = function (location) {
this.setHeader('Location', location)
this.writeHead(302)
this.end()
}
http
.createServer(async (req, res) => {
if (req.method !== 'GET') {
res.send(405, 'Method Not Allowed')
return
}
const path = req.url.split('?').shift()
if (path === '/') {
const encrypted = new URLSearchParams(req.headers.cookie?.replace(/; */g, '&')).get(process.env.COOKIE_NAME)
if (encrypted) {
try {
decrypt(encrypted)
res.send(200, '<h1>oauth3-proxy</h1><form action="/logout"><input type="submit" value="logout"/></form>')
} catch (error) {
console.warn(`Unable to decrypt cookie "${encrypted}"`)
console.warn(error.name, error.message)
res.setHeader('Set-Cookie', `${process.env.COOKIE_NAME}=;Max-Age=0;HttpOnly`)
res.send(401, error.message)
}
} else {
res.send(200, '<h1>oauth3-proxy</h1><form action="/login"><input type="submit" value="login"/></form>')
}
} else if (path === '/check') {
const encrypted = new URLSearchParams(req.headers.cookie?.replace(/; */g, '&')).get(process.env.COOKIE_NAME)
if (!encrypted) {
console.log(`Unauthorized - can not find "${process.env.COOKIE_NAME}" cookie in given cookies "${req.headers.cookie}"`)
res.send(401, 'Unauthorized')
return
}
try {
decrypt(encrypted)
res.send(200, 'OK')
} catch (error) {
console.warn(`Unable to decrypt cookie "${encrypted}"`)
console.warn(error.name, error.message)
res.send(401, error.message)
}
} else if (path === '/login') {
const url = new URL('https://github.com/login/oauth/authorize')
url.searchParams.set('client_id', process.env.CLIENT_ID)
url.searchParams.set('redirect_uri', process.env.REDIRECT_URL)
url.searchParams.set('scope', process.env.SCOPE)
url.searchParams.set('state', new URL(`http://localhost${req.url}`).searchParams.get('rd') || '/')
res.redirect(url)
} else if (path === '/callback') {
const query = new URL(`http://localhost${req.url}`).searchParams
const code = query.get('code')
const state = query.get('state') || '/'
try {
const accessToken = await exchange(code)
const encrypted = encrypt(accessToken)
const domain = process.env.COOKIE_DOMAIN ? `;Domain=${process.env.COOKIE_DOMAIN}` : ''
res.setHeader(
'Set-Cookie',
`${process.env.COOKIE_NAME}=${encrypted};Path=/;Max-Age=${process.env.COOKIE_MAX_AGE};HttpOnly${domain}`
)
res.redirect(state)
} catch (error) {
res.send(500, JSON.stringify(error, null, 4))
}
} else if (path === '/logout') {
res.setHeader('Set-Cookie', `${process.env.COOKIE_NAME}=;Max-Age=0;HttpOnly`)
res.redirect('/')
} else {
res.send(404, 'Not Found')
}
})
.listen(process.env.PORT, () => console.log(`Listening: 0.0.0.0:${process.env.PORT}`))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment