Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
module github.com/cego/go-lib/v2

go 1.25
go 1.25.0

require (
github.com/coreos/go-oidc/v3 v3.18.0
github.com/jarcoal/httpmock v1.4.1
github.com/stretchr/testify v1.11.1
golang.org/x/oauth2 v0.36.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
Expand All @@ -10,6 +14,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
49 changes: 49 additions & 0 deletions oidcauth/cookie.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package oidcauth

import (
"context"
"net/http"
"time"
)

const (
sessionCookie = "session"
stateCookie = "state"
nonceCookie = "nonce"
verifierCookie = "verifier"
returnCookie = "return"
)

func (o *OidcAuth) cookieName(name string) string {
return "__Host-" + o.cookiePrefix + "_" + name
}

func (o *OidcAuth) setCookie(w http.ResponseWriter, name, value string, maxAge time.Duration) {
http.SetCookie(w, &http.Cookie{
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Name: o.cookieName(name),
Value: value,
Path: "/",
MaxAge: int(maxAge.Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}

func (o *OidcAuth) clearCookie(w http.ResponseWriter, name string) {
o.setCookie(w, name, "", -time.Second)
}

type contextKey struct{}

func withUser(ctx context.Context, user User) context.Context {
return context.WithValue(ctx, contextKey{}, user)
}

func UserFromContext(ctx context.Context) User {
user, ok := ctx.Value(contextKey{}).(User)
if !ok {
return User{}
}
return user
}
262 changes: 262 additions & 0 deletions oidcauth/fakeidp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
package oidcauth_test

import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"maps"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
)

const (
testClientID = "test-client"
testClientSecret = "test-secret"
)

type fakeIDP struct {
server *httptest.Server
priv *rsa.PrivateKey
kid string

mu sync.Mutex
pending map[string]map[string]any
challenges map[string]string
nextUser map[string]any
redirects map[string]string
expiry time.Duration
}

func newFakeIDP(t *testing.T) *fakeIDP {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
idp := &fakeIDP{
priv: priv,
kid: "test-key",
pending: map[string]map[string]any{},
challenges: map[string]string{},
redirects: map[string]string{},
expiry: 5 * time.Minute,
}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", idp.handleDiscovery)
mux.HandleFunc("/jwks", idp.handleJWKS)
mux.HandleFunc("/auth", idp.handleAuthorize)
mux.HandleFunc("/token", idp.handleToken)
idp.server = httptest.NewTLSServer(mux)
t.Cleanup(idp.server.Close)
return idp
}

func (idp *fakeIDP) IssuerURL() string { return idp.server.URL }

func (idp *fakeIDP) Client() *http.Client { return idp.server.Client() }

func (idp *fakeIDP) AllowRedirectURI(uri string) {
idp.mu.Lock()
defer idp.mu.Unlock()
idp.redirects[uri] = uri
}

func (idp *fakeIDP) LoginAs(claims map[string]any) {
idp.mu.Lock()
defer idp.mu.Unlock()
idp.nextUser = claims
}

func (idp *fakeIDP) MintAccessToken(t *testing.T, audience string, claims map[string]any) string {
t.Helper()
all := idp.idClaims(claims)
all["aud"] = audience
all["azp"] = "some-cli-client"
token, err := idp.signJWT(all)
if err != nil {
t.Fatalf("sign jwt: %v", err)
}
return token
}

func (idp *fakeIDP) MintIDToken(t *testing.T, claims map[string]any) string {
t.Helper()
token, err := idp.signJWT(idp.idClaims(claims))
if err != nil {
t.Fatalf("sign jwt: %v", err)
}
return token
}

func (idp *fakeIDP) idClaims(userClaims map[string]any) map[string]any {
now := time.Now()
claims := map[string]any{
"iss": idp.server.URL,
"aud": testClientID,
"sub": "test-subject",
"iat": now.Unix(),
"exp": now.Add(idp.expiry).Unix(),
}
maps.Copy(claims, userClaims)
return claims
}

func (idp *fakeIDP) signJWT(claims map[string]any) (string, error) {
header, _ := json.Marshal(map[string]any{"alg": "RS256", "typ": "JWT", "kid": idp.kid})
body, _ := json.Marshal(claims)
enc := base64.RawURLEncoding
signingInput := enc.EncodeToString(header) + "." + enc.EncodeToString(body)
sum := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, idp.priv, crypto.SHA256, sum[:])
if err != nil {
return "", err
}
return signingInput + "." + enc.EncodeToString(sig), nil
}

func (idp *fakeIDP) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
u := idp.server.URL
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": u,
"authorization_endpoint": u + "/auth",
"token_endpoint": u + "/token",
"end_session_endpoint": u + "/logout",
"jwks_uri": u + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
})
}

func (idp *fakeIDP) handleJWKS(w http.ResponseWriter, _ *http.Request) {
pub := idp.priv.PublicKey
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]any{{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": idp.kid,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
}},
})
}

func (idp *fakeIDP) handleAuthorize(w http.ResponseWriter, r *http.Request) {
idp.mu.Lock()
redirectURI, registered := idp.redirects[r.URL.Query().Get("redirect_uri")]
idp.mu.Unlock()
if !registered {
http.Error(w, "redirect_uri is not registered for this client", http.StatusBadRequest)
return
}
challenge := r.URL.Query().Get("code_challenge")
if challenge == "" {
http.Error(w, "missing code_challenge", http.StatusBadRequest)
return
}
if method := r.URL.Query().Get("code_challenge_method"); method != "S256" {
http.Error(w, "code_challenge_method must be S256, got "+method, http.StatusBadRequest)
return
}

idp.mu.Lock()
user := idp.nextUser
idp.nextUser = nil
idp.mu.Unlock()
if user == nil {
http.Error(w, "fakeIDP: /auth called with no LoginAs primed", http.StatusBadRequest)
return
}

claims := map[string]any{}
maps.Copy(claims, user)
if nonce := r.URL.Query().Get("nonce"); nonce != "" {
claims["nonce"] = nonce
}

code, err := randomCode()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
idp.mu.Lock()
idp.pending[code] = claims
idp.challenges[code] = challenge
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
idp.mu.Unlock()

target, err := url.Parse(redirectURI)
if err != nil {
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
return
}
query := target.Query()
query.Set("code", code)
query.Set("state", r.URL.Query().Get("state"))
target.RawQuery = query.Encode()
http.Redirect(w, r, target.String(), http.StatusFound)
}

func (idp *fakeIDP) handleToken(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
clientID, clientSecret, ok := r.BasicAuth()
if !ok {
clientID, clientSecret = r.PostFormValue("client_id"), r.PostFormValue("client_secret")
}
if clientID != testClientID || clientSecret != testClientSecret {
http.Error(w, "invalid client", http.StatusUnauthorized)
return
}

code := r.PostFormValue("code")
idp.mu.Lock()
userClaims, found := idp.pending[code]
challenge := idp.challenges[code]
delete(idp.pending, code)
delete(idp.challenges, code)
idp.mu.Unlock()
if !found {
http.Error(w, "invalid code", http.StatusBadRequest)
return
}

sum := sha256.Sum256([]byte(r.PostFormValue("code_verifier")))
if base64.RawURLEncoding.EncodeToString(sum[:]) != challenge {
http.Error(w, "PKCE verifier does not match challenge", http.StatusBadRequest)
return
}

idToken, err := idp.signJWT(idp.idClaims(userClaims))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "not-used-by-oidcauth",
"id_token": idToken,
"token_type": "Bearer",
"expires_in": int(idp.expiry.Seconds()),
})
}

func randomCode() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
Loading