git.iko.soy/typeable/oauth2-proxy
on d291edb help sign in
7 files changed+3−494
since splitsnapshots
unifiedsplit
docs/docs/configuration/providers/dtt_sso.md−45 docs/docs/configuration/providers/index.md−1 docs/sidebars.js−1 pkg/apis/options/providers.go−3 providers/dtt_sso.go−202 providers/dtt_sso_test.go−237 providers/providers.go+3−5
docs/docs/configuration/providers/dtt_sso.mddeleted−45
@@ -1,45 +0,0 @@
1−---
2−id: dtt_sso
3−title: DTT SSO
4−---
5−
6−Signs users in with the DTT SSO (auth-server) using the authorization code flow and its HS256 id_token.
7−
8−1. Register a client in the auth-server config, one per protected service, with the proxy's callback as its
9− only redirect URI:
10−
11− ```yaml
12− clients:
13− redash-gate:
14− secret: <random secret>
15− redirect_uri: https://redash.example.com/oauth2/callback
16− display_name: Redash
17− ```
18−
19−2. Run the proxy with:
20−
21− ```shell
22− --provider=dtt-sso
23− --client-id=redash-gate
24− --client-secret-file=/run/secrets/client_secret
25− --redirect-url=https://redash.example.com/oauth2/callback
26− --cookie-secret-file=/run/secrets/cookie_secret
27− --cookie-name=_dtt_sso_redash
28− --cookie-refresh=5m
29− ```
30−
31− Give each instance its own `--cookie-name`: another oauth2-proxy setting its cookie on a parent domain
32− (the Atlassian proxy uses `_oauth2_proxy` on `.thebestagent.pro`) would otherwise shadow this one.
33−
34−The endpoints default to `https://auth.thebestagent.pro`. For another SSO host set `--login-url`, `--redeem-url`
35−and `--validate-url` to its `/oauth/authorize`, `/oauth/token` and `/oauth/check_token`; the id_token issuer must
36−equal the login URL's origin (the SSO's `site_host`).
37−
38−The scope must stay `openid`, the only scope the SSO accepts.
39−
40−The session carries the SSO `email`, the user UUID as the user, and the SSO `role` as the only group, so
41−`--allowed-group=dtt_admin` restricts access by role. `--authenticated-emails-file` and `--email-domain` restrict
42−by email as usual.
43−
44−With `--cookie-refresh` set, the proxy refreshes the tokens on that interval and re-reads the email and role; a user
45−deleted or signed out in the SSO loses access at the next refresh.
docs/docs/configuration/providers/index.md−1
@@ -14,7 +14,6 @@ Valid providers are :
1414- [Cidaas](cidaas.md)
1515- [CiscoDuo](cisco_duo.md)
1616- [DigitalOcean](digitalocean.md)
17−- [DTT SSO](dtt_sso.md)
1817- [Facebook](facebook.md)
1918- [Gitea](gitea.md)
2019- [GitHub](github.md)
docs/sidebars.js−1
@@ -52,7 +52,6 @@ const sidebars = {
5252 "configuration/providers/cidaas",
5353 "configuration/providers/cisco_duo",
5454 "configuration/providers/digitalocean",
55− "configuration/providers/dtt_sso",
5655 "configuration/providers/facebook",
5756 "configuration/providers/gitea",
5857 "configuration/providers/github",
pkg/apis/options/providers.go−3
@@ -169,9 +169,6 @@ const (
169169 // DigitalOceanProvider is the provider type for DigitalOcean
170170 DigitalOceanProvider ProviderType = "digitalocean"
171171
172− // DTTSSOProvider is the provider type for the DTT SSO
173− DTTSSOProvider ProviderType = "dtt-sso"
174−
175172 // FacebookProvider is the provider type for Facebook
176173 FacebookProvider ProviderType = "facebook"
177174
providers/dtt_sso.godeleted−202
@@ -1,202 +0,0 @@
1−package providers
2−
3−import (
4− "bytes"
5− "context"
6− "encoding/base64"
7− "errors"
8− "fmt"
9− "net/http"
10− "net/url"
11− "time"
12−
13− "github.com/golang-jwt/jwt/v5"
14− "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
15− "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
16−)
17−
18−// DTTSSOProvider signs users in with the DTT SSO (auth-server).
19−type DTTSSOProvider struct {
20− *ProviderData
21−}
22−
23−var _ Provider = (*DTTSSOProvider)(nil)
24−
25−const (
26− dttSSOProviderName = "DTT SSO"
27− dttSSODefaultScope = "openid"
28−)
29−
30−var (
31− dttSSODefaultLoginURL = &url.URL{
32− Scheme: "https",
33− Host: "auth.thebestagent.pro",
34− Path: "/oauth/authorize",
35− }
36−
37− dttSSODefaultRedeemURL = &url.URL{
38− Scheme: "https",
39− Host: "auth.thebestagent.pro",
40− Path: "/oauth/token",
41− }
42−
43− dttSSODefaultValidateURL = &url.URL{
44− Scheme: "https",
45− Host: "auth.thebestagent.pro",
46− Path: "/oauth/check_token",
47− }
48−)
49−
50−// NewDTTSSOProvider initiates a new DTTSSOProvider
51−func NewDTTSSOProvider(p *ProviderData) *DTTSSOProvider {
52− p.setProviderDefaults(providerDefaults{
53− name: dttSSOProviderName,
54− loginURL: dttSSODefaultLoginURL,
55− redeemURL: dttSSODefaultRedeemURL,
56− profileURL: nil,
57− validateURL: dttSSODefaultValidateURL,
58− scope: dttSSODefaultScope,
59− })
60− return &DTTSSOProvider{ProviderData: p}
61−}
62−
63−// dttSSOTokenResponse is the SSO's /oauth/token response.
64−type dttSSOTokenResponse struct {
65− AccessToken string `json:"access_token"`
66− RefreshToken string `json:"refresh_token"`
67− ExpiresIn int64 `json:"expires_in"`
68− IDToken string `json:"id_token"`
69−}
70−
71−// dttSSOClaims holds the id_token claims the provider uses.
72−type dttSSOClaims struct {
73− jwt.RegisteredClaims
74− Email string `json:"email"`
75− Role string `json:"role"`
76− UserID string `json:"user_id"`
77−}
78−
79−// Redeem exchanges the authorization code for a session.
80−func (p *DTTSSOProvider) Redeem(ctx context.Context, redirectURL, code, _ string) (*sessions.SessionState, error) {
81− if code == "" {
82− return nil, ErrMissingCode
83− }
84− return p.requestTokens(ctx, url.Values{
85− "grant_type": {"authorization_code"},
86− "code": {code},
87− "redirect_uri": {redirectURL},
88− })
89−}
90−
91−// RefreshSession renews the tokens and re-reads the email and role.
92−func (p *DTTSSOProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
93− if s == nil || s.RefreshToken == "" {
94− return false, nil
95− }
96− refreshed, err := p.requestTokens(ctx, url.Values{
97− "grant_type": {"refresh_token"},
98− "refresh_token": {s.RefreshToken},
99− })
100− if err != nil {
101− return false, fmt.Errorf("unable to redeem refresh token: %v", err)
102− }
103− s.AccessToken = refreshed.AccessToken
104− s.RefreshToken = refreshed.RefreshToken
105− s.IDToken = refreshed.IDToken
106− s.Email = refreshed.Email
107− s.User = refreshed.User
108− s.Groups = refreshed.Groups
109− s.CreatedAt = refreshed.CreatedAt
110− s.ExpiresOn = refreshed.ExpiresOn
111− return true, nil
112−}
113−
114−// ValidateSession checks the access token with the SSO's check_token endpoint.
115−func (p *DTTSSOProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
116− return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken))
117−}
118−
119−// requestTokens calls the token endpoint and builds a session from the verified id_token.
120−//
121−// The SSO compares the Basic credentials byte for byte, so they are sent unescaped
122−// rather than through golang.org/x/oauth2, which URL-escapes them.
123−func (p *DTTSSOProvider) requestTokens(ctx context.Context, params url.Values) (*sessions.SessionState, error) {
124− clientSecret, err := p.GetClientSecret()
125− if err != nil {
126− return nil, err
127− }
128− credentials := base64.StdEncoding.EncodeToString([]byte(p.ClientID + ":" + clientSecret))
129−
130− result := requests.New(p.RedeemURL.String()).
131− WithContext(ctx).
132− WithMethod(http.MethodPost).
133− WithBody(bytes.NewBufferString(params.Encode())).
134− SetHeader("Content-Type", "application/x-www-form-urlencoded").
135− SetHeader("Authorization", "Basic "+credentials).
136− Do()
137− if result.Error() != nil {
138− return nil, result.Error()
139− }
140− if result.StatusCode() != http.StatusOK {
141− return nil, fmt.Errorf("token request failed: status %d - %s", result.StatusCode(), result.Body())
142− }
143−
144− var token dttSSOTokenResponse
145− if err := result.UnmarshalInto(&token); err != nil {
146− return nil, err
147− }
148− if token.AccessToken == "" {
149− return nil, errors.New("token response carried no access_token")
150− }
151− claims, err := p.verifyIDToken(token.IDToken, clientSecret)
152− if err != nil {
153− return nil, err
154− }
155−
156− s := &sessions.SessionState{
157− AccessToken: token.AccessToken,
158− RefreshToken: token.RefreshToken,
159− IDToken: token.IDToken,
160− Email: claims.Email,
161− User: claims.UserID,
162− }
163− if claims.Role != "" {
164− s.Groups = []string{claims.Role}
165− }
166− s.CreatedAtNow()
167− if token.ExpiresIn > 0 {
168− s.ExpiresIn(time.Duration(token.ExpiresIn) * time.Second)
169− }
170− return s, nil
171−}
172−
173−// verifyIDToken checks the id_token's HS256 signature (keyed on the client secret),
174−// issuer, audience and expiry.
175−//
176−// go-jose is not used: it rejects HMAC keys under 32 bytes, and SSO client secrets
177−// can be shorter.
178−func (p *DTTSSOProvider) verifyIDToken(raw, clientSecret string) (*dttSSOClaims, error) {
179− if raw == "" {
180− return nil, errors.New("token response carried no id_token; the scope must be exactly \"openid\"")
181− }
182− var claims dttSSOClaims
183− _, err := jwt.ParseWithClaims(raw, &claims,
184− func(*jwt.Token) (any, error) { return []byte(clientSecret), nil },
185− jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
186− jwt.WithIssuer(p.issuer()),
187− jwt.WithAudience(p.ClientID),
188− jwt.WithExpirationRequired(),
189− )
190− if err != nil {
191− return nil, fmt.Errorf("verifying id_token: %v", err)
192− }
193− if claims.Email == "" {
194− return nil, errors.New("id_token carries no email")
195− }
196− return &claims, nil
197−}
198−
199−// issuer is the SSO's site host, the origin of the login URL.
200−func (p *DTTSSOProvider) issuer() string {
201− return p.LoginURL.Scheme + "://" + p.LoginURL.Host
202−}
providers/dtt_sso_test.godeleted−237
@@ -1,237 +0,0 @@
1−package providers
2−
3−import (
4− "context"
5− "encoding/base64"
6− "encoding/json"
7− "net/http"
8− "net/http/httptest"
9− "net/url"
10− "testing"
11− "time"
12−
13− "github.com/golang-jwt/jwt/v5"
14− "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
15− . "github.com/onsi/gomega"
16−)
17−
18−const (
19− dttSSOTestClientID = "redash-gate"
20− // Shorter than 32 bytes, and contains characters golang.org/x/oauth2 would
21− // URL-escape in Basic auth.
22− dttSSOTestSecret = "s3cr+t/abc=="
23−)
24−
25−// dttSSOTestServer mimics the SSO's token and check_token endpoints.
26−type dttSSOTestServer struct {
27− *httptest.Server
28− claims map[string]interface{}
29− signingKey []byte
30− lastForm url.Values
31− validTokens map[string]bool
32− tokenCounter int
33−}
34−
35−func newDTTSSOTestServer() *dttSSOTestServer {
36− s := &dttSSOTestServer{
37− signingKey: []byte(dttSSOTestSecret),
38− validTokens: map[string]bool{},
39− }
40− s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
41− u, _ := url.Parse(s.URL)
42− s.claims = map[string]interface{}{
43− "iss": "http://" + u.Host,
44− "aud": dttSSOTestClientID,
45− "sub": "0b5c1e1a-1111-2222-3333-444455556666",
46− "user_id": "0b5c1e1a-1111-2222-3333-444455556666",
47− "email": "admin@example.com",
48− "role": "dtt_admin",
49− "exp": time.Now().Add(time.Hour).Unix(),
50− }
51− return s
52−}
53−
54−func (s *dttSSOTestServer) handle(w http.ResponseWriter, r *http.Request) {
55− switch r.URL.Path {
56− case "/oauth/token":
57− want := "Basic " + base64.StdEncoding.EncodeToString([]byte(dttSSOTestClientID+":"+dttSSOTestSecret))
58− if r.Header.Get("Authorization") != want {
59− w.WriteHeader(http.StatusUnauthorized)
60− return
61− }
62− _ = r.ParseForm()
63− s.lastForm = r.PostForm
64− if r.PostForm.Get("grant_type") == "refresh_token" && !s.validTokens["refresh-"+r.PostForm.Get("refresh_token")] {
65− w.WriteHeader(http.StatusBadRequest)
66− _, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"Invalid refresh token"}`))
67− return
68− }
69− s.tokenCounter++
70− access := "access-" + string(rune('0'+s.tokenCounter))
71− refresh := "refresh-" + string(rune('0'+s.tokenCounter))
72− s.validTokens[access] = true
73− s.validTokens["refresh-"+refresh] = true
74− w.Header().Set("Content-Type", "application/json")
75− _ = json.NewEncoder(w).Encode(map[string]interface{}{
76− "access_token": access,
77− "token_type": "bearer",
78− "expires_in": 3600,
79− "refresh_token": refresh,
80− "id_token": s.idToken(),
81− "scope": "openid",
82− })
83− case "/oauth/check_token":
84− token := r.Header.Get("Authorization")
85− if len(token) > len("Bearer ") && s.validTokens[token[len("Bearer "):]] {
86− _, _ = w.Write([]byte(`{"user_id":"0b5c1e1a-1111-2222-3333-444455556666","real_user_id":null,"issued_by":"redash-gate"}`))
87− return
88− }
89− w.WriteHeader(http.StatusUnauthorized)
90− _, _ = w.Write([]byte("Invalid access token"))
91− default:
92− w.WriteHeader(http.StatusNotFound)
93− }
94−}
95−
96−func (s *dttSSOTestServer) idToken() string {
97− if s.claims == nil {
98− return ""
99− }
100− raw, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(s.claims)).SignedString(s.signingKey)
101− if err != nil {
102− panic(err)
103− }
104− return raw
105−}
106−
107−func testDTTSSOProvider(server *dttSSOTestServer) *DTTSSOProvider {
108− p := NewDTTSSOProvider(&ProviderData{ClientID: dttSSOTestClientID, ClientSecret: dttSSOTestSecret})
109− u, _ := url.Parse(server.URL)
110− updateURL(p.LoginURL, u.Host)
111− updateURL(p.RedeemURL, u.Host)
112− updateURL(p.ValidateURL, u.Host)
113− return p
114−}
115−
116−func TestNewDTTSSOProvider(t *testing.T) {
117− g := NewWithT(t)
118−
119− providerData := NewDTTSSOProvider(&ProviderData{}).Data()
120− g.Expect(providerData.ProviderName).To(Equal("DTT SSO"))
121− g.Expect(providerData.LoginURL.String()).To(Equal("https://auth.thebestagent.pro/oauth/authorize"))
122− g.Expect(providerData.RedeemURL.String()).To(Equal("https://auth.thebestagent.pro/oauth/token"))
123− g.Expect(providerData.ValidateURL.String()).To(Equal("https://auth.thebestagent.pro/oauth/check_token"))
124− g.Expect(providerData.Scope).To(Equal("openid"))
125−}
126−
127−func TestDTTSSOProviderRedeem(t *testing.T) {
128− g := NewWithT(t)
129− server := newDTTSSOTestServer()
130− defer server.Close()
131− p := testDTTSSOProvider(server)
132−
133− s, err := p.Redeem(context.Background(), "https://redash.example.com/oauth2/callback", "the-code", "")
134− g.Expect(err).ToNot(HaveOccurred())
135− g.Expect(s.Email).To(Equal("admin@example.com"))
136− g.Expect(s.User).To(Equal("0b5c1e1a-1111-2222-3333-444455556666"))
137− g.Expect(s.Groups).To(Equal([]string{"dtt_admin"}))
138− g.Expect(s.AccessToken).To(Equal("access-1"))
139− g.Expect(s.RefreshToken).To(Equal("refresh-1"))
140− g.Expect(s.IDToken).ToNot(BeEmpty())
141− g.Expect(s.ExpiresOn).ToNot(BeNil())
142− g.Expect(*s.ExpiresOn).To(BeTemporally("~", time.Now().Add(time.Hour), time.Minute))
143−
144− g.Expect(server.lastForm.Get("grant_type")).To(Equal("authorization_code"))
145− g.Expect(server.lastForm.Get("code")).To(Equal("the-code"))
146− g.Expect(server.lastForm.Get("redirect_uri")).To(Equal("https://redash.example.com/oauth2/callback"))
147−}
148−
149−func TestDTTSSOProviderRedeemRejectsBadIDTokens(t *testing.T) {
150− testCases := map[string]func(s *dttSSOTestServer){
151− "signed with another secret": func(s *dttSSOTestServer) { s.signingKey = []byte("some-other-client-secret") },
152− "other audience": func(s *dttSSOTestServer) { s.claims["aud"] = "frappe-gate" },
153− "other issuer": func(s *dttSSOTestServer) { s.claims["iss"] = "https://evil.example.com" },
154− "expired": func(s *dttSSOTestServer) { s.claims["exp"] = time.Now().Add(-time.Minute).Unix() },
155− "no email": func(s *dttSSOTestServer) { delete(s.claims, "email") },
156− "no id_token": func(s *dttSSOTestServer) { s.claims = nil },
157− }
158− for name, tamper := range testCases {
159− t.Run(name, func(t *testing.T) {
160− g := NewWithT(t)
161− server := newDTTSSOTestServer()
162− defer server.Close()
163− tamper(server)
164−
165− _, err := testDTTSSOProvider(server).Redeem(context.Background(), "https://redash.example.com/oauth2/callback", "the-code", "")
166− g.Expect(err).To(HaveOccurred())
167− })
168− }
169−}
170−
171−func TestDTTSSOProviderRedeemRejectsUnsignedIDToken(t *testing.T) {
172− g := NewWithT(t)
173− server := newDTTSSOTestServer()
174− defer server.Close()
175− p := testDTTSSOProvider(server)
176−
177− header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
178− payload, _ := json.Marshal(server.claims)
179− unsigned := header + "." + base64.RawURLEncoding.EncodeToString(payload) + "."
180−
181− _, err := p.verifyIDToken(unsigned, dttSSOTestSecret)
182− g.Expect(err).To(HaveOccurred())
183−}
184−
185−func TestDTTSSOProviderRefreshSession(t *testing.T) {
186− g := NewWithT(t)
187− server := newDTTSSOTestServer()
188− defer server.Close()
189− p := testDTTSSOProvider(server)
190−
191− s, err := p.Redeem(context.Background(), "https://redash.example.com/oauth2/callback", "the-code", "")
192− g.Expect(err).ToNot(HaveOccurred())
193− nonce := []byte("keep-me")
194− s.Nonce = nonce
195−
196− server.claims["role"] = "agent"
197− server.claims["email"] = "renamed@example.com"
198− refreshed, err := p.RefreshSession(context.Background(), s)
199− g.Expect(err).ToNot(HaveOccurred())
200− g.Expect(refreshed).To(BeTrue())
201− g.Expect(s.AccessToken).To(Equal("access-2"))
202− g.Expect(s.RefreshToken).To(Equal("refresh-2"))
203− g.Expect(s.Groups).To(Equal([]string{"agent"}))
204− g.Expect(s.Email).To(Equal("renamed@example.com"))
205− g.Expect(s.Nonce).To(Equal(nonce))
206− g.Expect(server.lastForm.Get("grant_type")).To(Equal("refresh_token"))
207− g.Expect(server.lastForm.Get("refresh_token")).To(Equal("refresh-1"))
208−}
209−
210−func TestDTTSSOProviderRefreshSessionFailures(t *testing.T) {
211− g := NewWithT(t)
212− server := newDTTSSOTestServer()
213− defer server.Close()
214− p := testDTTSSOProvider(server)
215−
216− refreshed, err := p.RefreshSession(context.Background(), &sessions.SessionState{})
217− g.Expect(err).ToNot(HaveOccurred())
218− g.Expect(refreshed).To(BeFalse())
219−
220− refreshed, err = p.RefreshSession(context.Background(), &sessions.SessionState{RefreshToken: "revoked"})
221− g.Expect(err).To(HaveOccurred())
222− g.Expect(refreshed).To(BeFalse())
223−}
224−
225−func TestDTTSSOProviderValidateSession(t *testing.T) {
226− g := NewWithT(t)
227− server := newDTTSSOTestServer()
228− defer server.Close()
229− p := testDTTSSOProvider(server)
230−
231− s, err := p.Redeem(context.Background(), "https://redash.example.com/oauth2/callback", "the-code", "")
232− g.Expect(err).ToNot(HaveOccurred())
233− g.Expect(p.ValidateSession(context.Background(), s)).To(BeTrue())
234−
235− delete(server.validTokens, s.AccessToken)
236− g.Expect(p.ValidateSession(context.Background(), s)).To(BeFalse())
237−}
providers/providers.go+3−5
@@ -52,8 +52,6 @@ func NewProvider(providerConfig options.Provider) (Provider, error) {
5252 return NewCIDAASProvider(providerData, providerConfig), nil
5353 case options.DigitalOceanProvider:
5454 return NewDigitalOceanProvider(providerData), nil
55− case options.DTTSSOProvider:
56− return NewDTTSSOProvider(providerData), nil
5755 case options.FacebookProvider:
5856 return NewFacebookProvider(providerData), nil
5957 case options.GitHubProvider:
@@ -193,9 +191,9 @@ func parseCodeChallengeMethod(providerConfig options.Provider) string {
193191
194192func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bool, error) {
195193 switch providerType {
196− case options.AtlassianProvider, options.BitbucketProvider, options.DigitalOceanProvider, options.DTTSSOProvider,
197− options.FacebookProvider, options.GitHubProvider, options.GoogleProvider, options.KeycloakProvider,
198− options.LinkedInProvider, options.LoginGovProvider, options.NextCloudProvider, options.SourceHutProvider:
194+ case options.AtlassianProvider, options.BitbucketProvider, options.DigitalOceanProvider, options.FacebookProvider,
195+ options.GitHubProvider, options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider,
196+ options.LoginGovProvider, options.NextCloudProvider, options.SourceHutProvider:
199197 return false, nil
200198 case options.OIDCProvider, options.ADFSProvider, options.AzureProvider, options.CidaasProvider,
201199 options.GitLabProvider, options.KeycloakOIDCProvider, options.MicrosoftEntraIDProvider: