Created
November 22, 2021 07:59
-
-
Save mac2000/5aa4448b89fa74c390da9501728f2e54 to your computer and use it in GitHub Desktop.
kubernetes auth-proxy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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