Skip to content

Commit 09f67c7

Browse files
committed
Add oidcauth package
Authorization code flow with PKCE against an OIDC provider, with the verified id token kept in an httpOnly cookie as the session so there is no session store to run, and role gating read from the client_roles claim. Services that need a browser login hand-roll this today: bearer-guard spends 450 lines on it in package main, so none of it is importable, and mysql-admin another 300.
1 parent 0fa3bf1 commit 09f67c7

7 files changed

Lines changed: 1458 additions & 1 deletion

File tree

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
module github.com/cego/go-lib/v2
22

3-
go 1.25
3+
go 1.25.0
44

55
require (
6+
github.com/coreos/go-oidc/v3 v3.18.0
67
github.com/jarcoal/httpmock v1.4.1
78
github.com/stretchr/testify v1.11.1
9+
golang.org/x/oauth2 v0.36.0
810
)
911

1012
require (
1113
github.com/davecgh/go-spew v1.1.1 // indirect
14+
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
1215
github.com/pmezard/go-difflib v1.0.0 // indirect
1316
github.com/stretchr/objx v0.5.2 // indirect
1417
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
2+
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
13
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
24
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
6+
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
37
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
48
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
59
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
@@ -10,6 +14,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
1014
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
1115
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
1216
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
17+
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
18+
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
1319
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1420
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1521
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

oidcauth/cookie.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package oidcauth
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"time"
7+
)
8+
9+
const (
10+
sessionCookie = "session"
11+
stateCookie = "state"
12+
nonceCookie = "nonce"
13+
verifierCookie = "verifier"
14+
returnCookie = "return"
15+
)
16+
17+
func (o *OidcAuth) cookieName(name string) string {
18+
return "__Host-" + o.cookiePrefix + "_" + name
19+
}
20+
21+
func (o *OidcAuth) setCookie(w http.ResponseWriter, name, value string, maxAge time.Duration) {
22+
http.SetCookie(w, &http.Cookie{
23+
Name: o.cookieName(name),
24+
Value: value,
25+
Path: "/",
26+
MaxAge: int(maxAge.Seconds()),
27+
HttpOnly: true,
28+
Secure: true,
29+
SameSite: http.SameSiteLaxMode,
30+
})
31+
}
32+
33+
func (o *OidcAuth) clearCookie(w http.ResponseWriter, name string) {
34+
o.setCookie(w, name, "", -time.Second)
35+
}
36+
37+
type contextKey struct{}
38+
39+
func withUser(ctx context.Context, user User) context.Context {
40+
return context.WithValue(ctx, contextKey{}, user)
41+
}
42+
43+
func UserFromContext(ctx context.Context) User {
44+
user, ok := ctx.Value(contextKey{}).(User)
45+
if !ok {
46+
return User{}
47+
}
48+
return user
49+
}

oidcauth/fakeidp_test.go

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package oidcauth_test
2+
3+
import (
4+
"crypto"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"crypto/sha256"
8+
"encoding/base64"
9+
"encoding/json"
10+
"maps"
11+
"math/big"
12+
"net/http"
13+
"net/http/httptest"
14+
"net/url"
15+
"sync"
16+
"testing"
17+
"time"
18+
)
19+
20+
const (
21+
testClientID = "test-client"
22+
testClientSecret = "test-secret"
23+
)
24+
25+
type fakeIDP struct {
26+
server *httptest.Server
27+
priv *rsa.PrivateKey
28+
kid string
29+
30+
mu sync.Mutex
31+
pending map[string]map[string]any
32+
challenges map[string]string
33+
nextUser map[string]any
34+
redirects map[string]string
35+
expiry time.Duration
36+
}
37+
38+
func newFakeIDP(t *testing.T) *fakeIDP {
39+
t.Helper()
40+
priv, err := rsa.GenerateKey(rand.Reader, 2048)
41+
if err != nil {
42+
t.Fatalf("rsa key: %v", err)
43+
}
44+
idp := &fakeIDP{
45+
priv: priv,
46+
kid: "test-key",
47+
pending: map[string]map[string]any{},
48+
challenges: map[string]string{},
49+
redirects: map[string]string{},
50+
expiry: 5 * time.Minute,
51+
}
52+
mux := http.NewServeMux()
53+
mux.HandleFunc("/.well-known/openid-configuration", idp.handleDiscovery)
54+
mux.HandleFunc("/jwks", idp.handleJWKS)
55+
mux.HandleFunc("/auth", idp.handleAuthorize)
56+
mux.HandleFunc("/token", idp.handleToken)
57+
idp.server = httptest.NewTLSServer(mux)
58+
t.Cleanup(idp.server.Close)
59+
return idp
60+
}
61+
62+
func (idp *fakeIDP) IssuerURL() string { return idp.server.URL }
63+
64+
func (idp *fakeIDP) Client() *http.Client { return idp.server.Client() }
65+
66+
func (idp *fakeIDP) AllowRedirectURI(uri string) {
67+
idp.mu.Lock()
68+
defer idp.mu.Unlock()
69+
idp.redirects[uri] = uri
70+
}
71+
72+
func (idp *fakeIDP) LoginAs(claims map[string]any) {
73+
idp.mu.Lock()
74+
defer idp.mu.Unlock()
75+
idp.nextUser = claims
76+
}
77+
78+
func (idp *fakeIDP) MintAccessToken(t *testing.T, audience string, claims map[string]any) string {
79+
t.Helper()
80+
all := idp.idClaims(claims)
81+
all["aud"] = audience
82+
all["azp"] = "some-cli-client"
83+
token, err := idp.signJWT(all)
84+
if err != nil {
85+
t.Fatalf("sign jwt: %v", err)
86+
}
87+
return token
88+
}
89+
90+
func (idp *fakeIDP) MintIDToken(t *testing.T, claims map[string]any) string {
91+
t.Helper()
92+
token, err := idp.signJWT(idp.idClaims(claims))
93+
if err != nil {
94+
t.Fatalf("sign jwt: %v", err)
95+
}
96+
return token
97+
}
98+
99+
func (idp *fakeIDP) idClaims(userClaims map[string]any) map[string]any {
100+
now := time.Now()
101+
claims := map[string]any{
102+
"iss": idp.server.URL,
103+
"aud": testClientID,
104+
"sub": "test-subject",
105+
"iat": now.Unix(),
106+
"exp": now.Add(idp.expiry).Unix(),
107+
}
108+
maps.Copy(claims, userClaims)
109+
return claims
110+
}
111+
112+
func (idp *fakeIDP) signJWT(claims map[string]any) (string, error) {
113+
header, _ := json.Marshal(map[string]any{"alg": "RS256", "typ": "JWT", "kid": idp.kid})
114+
body, _ := json.Marshal(claims)
115+
enc := base64.RawURLEncoding
116+
signingInput := enc.EncodeToString(header) + "." + enc.EncodeToString(body)
117+
sum := sha256.Sum256([]byte(signingInput))
118+
sig, err := rsa.SignPKCS1v15(rand.Reader, idp.priv, crypto.SHA256, sum[:])
119+
if err != nil {
120+
return "", err
121+
}
122+
return signingInput + "." + enc.EncodeToString(sig), nil
123+
}
124+
125+
func (idp *fakeIDP) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
126+
u := idp.server.URL
127+
w.Header().Set("Content-Type", "application/json")
128+
_ = json.NewEncoder(w).Encode(map[string]any{
129+
"issuer": u,
130+
"authorization_endpoint": u + "/auth",
131+
"token_endpoint": u + "/token",
132+
"end_session_endpoint": u + "/logout",
133+
"jwks_uri": u + "/jwks",
134+
"id_token_signing_alg_values_supported": []string{"RS256"},
135+
"response_types_supported": []string{"code"},
136+
"subject_types_supported": []string{"public"},
137+
})
138+
}
139+
140+
func (idp *fakeIDP) handleJWKS(w http.ResponseWriter, _ *http.Request) {
141+
pub := idp.priv.PublicKey
142+
w.Header().Set("Content-Type", "application/json")
143+
_ = json.NewEncoder(w).Encode(map[string]any{
144+
"keys": []map[string]any{{
145+
"kty": "RSA",
146+
"use": "sig",
147+
"alg": "RS256",
148+
"kid": idp.kid,
149+
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
150+
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
151+
}},
152+
})
153+
}
154+
155+
func (idp *fakeIDP) handleAuthorize(w http.ResponseWriter, r *http.Request) {
156+
idp.mu.Lock()
157+
redirectURI, registered := idp.redirects[r.URL.Query().Get("redirect_uri")]
158+
idp.mu.Unlock()
159+
if !registered {
160+
http.Error(w, "redirect_uri is not registered for this client", http.StatusBadRequest)
161+
return
162+
}
163+
challenge := r.URL.Query().Get("code_challenge")
164+
if challenge == "" {
165+
http.Error(w, "missing code_challenge", http.StatusBadRequest)
166+
return
167+
}
168+
if method := r.URL.Query().Get("code_challenge_method"); method != "S256" {
169+
http.Error(w, "code_challenge_method must be S256, got "+method, http.StatusBadRequest)
170+
return
171+
}
172+
173+
idp.mu.Lock()
174+
user := idp.nextUser
175+
idp.nextUser = nil
176+
idp.mu.Unlock()
177+
if user == nil {
178+
http.Error(w, "fakeIDP: /auth called with no LoginAs primed", http.StatusBadRequest)
179+
return
180+
}
181+
182+
claims := map[string]any{}
183+
maps.Copy(claims, user)
184+
if nonce := r.URL.Query().Get("nonce"); nonce != "" {
185+
claims["nonce"] = nonce
186+
}
187+
188+
code, err := randomCode()
189+
if err != nil {
190+
http.Error(w, err.Error(), http.StatusInternalServerError)
191+
return
192+
}
193+
idp.mu.Lock()
194+
idp.pending[code] = claims
195+
idp.challenges[code] = challenge
196+
idp.mu.Unlock()
197+
198+
target, err := url.Parse(redirectURI)
199+
if err != nil {
200+
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
201+
return
202+
}
203+
query := target.Query()
204+
query.Set("code", code)
205+
query.Set("state", r.URL.Query().Get("state"))
206+
target.RawQuery = query.Encode()
207+
http.Redirect(w, r, target.String(), http.StatusFound)
208+
}
209+
210+
func (idp *fakeIDP) handleToken(w http.ResponseWriter, r *http.Request) {
211+
if err := r.ParseForm(); err != nil {
212+
http.Error(w, err.Error(), http.StatusBadRequest)
213+
return
214+
}
215+
clientID, clientSecret, ok := r.BasicAuth()
216+
if !ok {
217+
clientID, clientSecret = r.PostFormValue("client_id"), r.PostFormValue("client_secret")
218+
}
219+
if clientID != testClientID || clientSecret != testClientSecret {
220+
http.Error(w, "invalid client", http.StatusUnauthorized)
221+
return
222+
}
223+
224+
code := r.PostFormValue("code")
225+
idp.mu.Lock()
226+
userClaims, found := idp.pending[code]
227+
challenge := idp.challenges[code]
228+
delete(idp.pending, code)
229+
delete(idp.challenges, code)
230+
idp.mu.Unlock()
231+
if !found {
232+
http.Error(w, "invalid code", http.StatusBadRequest)
233+
return
234+
}
235+
236+
sum := sha256.Sum256([]byte(r.PostFormValue("code_verifier")))
237+
if base64.RawURLEncoding.EncodeToString(sum[:]) != challenge {
238+
http.Error(w, "PKCE verifier does not match challenge", http.StatusBadRequest)
239+
return
240+
}
241+
242+
idToken, err := idp.signJWT(idp.idClaims(userClaims))
243+
if err != nil {
244+
http.Error(w, err.Error(), http.StatusInternalServerError)
245+
return
246+
}
247+
w.Header().Set("Content-Type", "application/json")
248+
_ = json.NewEncoder(w).Encode(map[string]any{
249+
"access_token": "not-used-by-oidcauth",
250+
"id_token": idToken,
251+
"token_type": "Bearer",
252+
"expires_in": int(idp.expiry.Seconds()),
253+
})
254+
}
255+
256+
func randomCode() (string, error) {
257+
buf := make([]byte, 16)
258+
if _, err := rand.Read(buf); err != nil {
259+
return "", err
260+
}
261+
return base64.RawURLEncoding.EncodeToString(buf), nil
262+
}

0 commit comments

Comments
 (0)