From 960045594cd4f98fde9fbf02b273718ded5e9d35 Mon Sep 17 00:00:00 2001 From: Robin Thellend Date: Wed, 13 Nov 2024 19:37:06 -0800 Subject: [PATCH 01/11] Expand passkeys to work without resident keys --- proxy/internal/passkeys/auth-template.html | 8 +++++- proxy/internal/passkeys/manager.go | 30 +++++++++++++++++++--- proxy/internal/passkeys/testutils.go | 2 +- proxy/internal/passkeys/webauthn.go | 6 ++--- proxy/internal/passkeys/webauthn.js | 12 ++++++++- proxy/sso_test.go | 2 +- 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/proxy/internal/passkeys/auth-template.html b/proxy/internal/passkeys/auth-template.html index f2818ed..516bb2e 100644 --- a/proxy/internal/passkeys/auth-template.html +++ b/proxy/internal/passkeys/auth-template.html @@ -23,6 +23,11 @@ #message { width: fit-content; } +#loginid { + font-size: 125%; + margin-top: 2rem; + width: 20rem; +} @@ -40,7 +45,8 @@
🛂
Authentication required to access
{{.DisplayURL}}
-
Continue
+
+
Continue
{{- end }} {{- if eq .Mode "RefreshID" }}
{{.Email}}
diff --git a/proxy/internal/passkeys/manager.go b/proxy/internal/passkeys/manager.go index 00bd076..58b8ed6 100644 --- a/proxy/internal/passkeys/manager.go +++ b/proxy/internal/passkeys/manager.go @@ -352,7 +352,7 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { http.Error(w, "invalid request", http.StatusBadRequest) return } - opts, err := m.assertionOptions() + opts, err := m.assertionOptions(req.PostForm.Get("loginId")) if err != nil { m.cfg.Logger.Errorf("ERR assertionOptions: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -896,7 +896,7 @@ func (m *Manager) processAttestation(claims map[string]any, host, jsargs string, return c, commit(true, nil) } -func (m *Manager) assertionOptions() (*AssertionOptions, error) { +func (m *Manager) assertionOptions(email string) (*AssertionOptions, error) { m.vacuum() opts, err := newAssertionOptions() if err != nil { @@ -907,6 +907,23 @@ func (m *Manager) assertionOptions() (*AssertionOptions, error) { m.challenges[base64.RawURLEncoding.EncodeToString(opts.Challenge)] = &challenge{ created: time.Now().UTC(), } + if h, ok := m.db.Subjects[email]; ok { + if u, ok := m.db.Handles[h]; ok { + for _, key := range u.Keys { + opts.AllowCredentials = append(opts.AllowCredentials, CredentialID{ + Type: "public-key", + ID: key.ID, + Transports: key.Transports, + }) + } + } + } else if email != "" { + opts.AllowCredentials = append(opts.AllowCredentials, CredentialID{ + Type: "public-key", + ID: Bytes{0xff}, + Transports: []string{"internal"}, + }) + } return opts, nil } @@ -918,6 +935,7 @@ func (m *Manager) processAssertion(jsargs string, token *jwt.Token) (claims map[ AuthenticatorData Bytes `json:"authenticatorData"` Signature Bytes `json:"signature"` UserHandle Bytes `json:"userHandle"` + LoginID string `json:"loginId"` } if err := json.Unmarshal([]byte(jsargs), &args); err != nil { return nil, err @@ -953,7 +971,13 @@ func (m *Manager) processAssertion(jsargs string, token *jwt.Token) (claims map[ } defer commit(false, &retErr) - u, ok := m.db.Handles[base64.RawURLEncoding.EncodeToString(args.UserHandle)] + userHandle := base64.RawURLEncoding.EncodeToString(args.UserHandle) + if userHandle == "" && args.LoginID != "" { + if h, ok := m.db.Subjects[args.LoginID]; ok { + userHandle = h + } + } + u, ok := m.db.Handles[userHandle] if !ok { return nil, errors.New("invalid userHandle") } diff --git a/proxy/internal/passkeys/testutils.go b/proxy/internal/passkeys/testutils.go index b13590c..1d11b34 100644 --- a/proxy/internal/passkeys/testutils.go +++ b/proxy/internal/passkeys/testutils.go @@ -102,7 +102,7 @@ func (a *FakeAuthenticator) Create(options *AttestationOptions) (clientDataJSON, } authKey.uid = options.User.ID - authKey.rk = options.AuthenticatorSelection.RequireResidentKey + authKey.rk = options.AuthenticatorSelection.ResidentKey == "preferred" || options.AuthenticatorSelection.ResidentKey == "required" authKey.id = make([]byte, 32) if _, err := rand.Read(authKey.id); err != nil { diff --git a/proxy/internal/passkeys/webauthn.go b/proxy/internal/passkeys/webauthn.go index 9d9ac76..087d684 100644 --- a/proxy/internal/passkeys/webauthn.go +++ b/proxy/internal/passkeys/webauthn.go @@ -86,8 +86,8 @@ type AttestationOptions struct { AuthenticatorSelection struct { // required, preferred, or discouraged UserVerification string `json:"userVerification"` - // Whether we want discoverable credentials. - RequireResidentKey bool `json:"requireResidentKey"` + // required, preferred, or discouraged + ResidentKey string `json:"residentKey"` } `json:"authenticatorSelection"` // Extensions. Extensions map[string]interface{} `json:"extensions,omitempty"` @@ -112,7 +112,7 @@ func newAttestationOptions() (*AttestationOptions, error) { Attestation: "none", } ao.AuthenticatorSelection.UserVerification = "required" - ao.AuthenticatorSelection.RequireResidentKey = true + ao.AuthenticatorSelection.ResidentKey = "preferred" challenge := make([]byte, 32) if _, err := rand.Read(challenge); err != nil { diff --git a/proxy/internal/passkeys/webauthn.js b/proxy/internal/passkeys/webauthn.js index 7f3ae36..355d5fa 100644 --- a/proxy/internal/passkeys/webauthn.js +++ b/proxy/internal/passkeys/webauthn.js @@ -90,15 +90,21 @@ function registerPasskey(token) { }); } -function loginWithPasskey(token) { +function loginWithPasskey(token, loginId) { if (!('PublicKeyCredential' in window)) { throw new Error('Browser doesn\'t support WebAuthn'); } + let body = null; + if (loginId) { + body = 'loginId=' + encodeURIComponent(loginId) + } fetch('?get=AssertionOptions&redirect='+token, { method: 'POST', headers: { + 'content-type': 'application/x-www-form-urlencoded', 'x-csrf-check': 1, }, + body: body, }) .then(resp => { if (resp.status !== 200) { @@ -108,6 +114,9 @@ function loginWithPasskey(token) { }) .then(options => { options.challenge = new Uint8Array(options.challenge); + for (let i = 0; i < options.allowCredentials.length; i++) { + options.allowCredentials[i].id = new Uint8Array(options.allowCredentials[i].id); + } return navigator.credentials.get({publicKey: options}) }) .then(pkc => { @@ -120,6 +129,7 @@ function loginWithPasskey(token) { authenticatorData: Array.from(new Uint8Array(pkc.response.authenticatorData)), signature: Array.from(new Uint8Array(pkc.response.signature)), userHandle: Array.from(new Uint8Array(pkc.response.userHandle)), + loginId: loginId, }); return fetch('?get=Check&redirect='+token, { method: 'POST', diff --git a/proxy/sso_test.go b/proxy/sso_test.go index 7066541..0f7381a 100644 --- a/proxy/sso_test.go +++ b/proxy/sso_test.go @@ -451,7 +451,7 @@ func TestSSOEnforcePasskey(t *testing.T) { if got, want := code, 200; got != want { t.Errorf("Code = %v, want %v", got, want) } - m = regexp.MustCompile(`loginWithPasskey\("(.*)"\)`).FindStringSubmatch(body) + m = regexp.MustCompile(`loginWithPasskey\("(.*)",`).FindStringSubmatch(body) if len(m) != 2 { t.Fatalf("FindStringSubmatch: %v", m) } From 43f66e1a9b219a39a7e00b3e60d2bd606fa49218 Mon Sep 17 00:00:00 2001 From: Robin Thellend Date: Wed, 13 Nov 2024 20:43:38 -0800 Subject: [PATCH 02/11] more cases --- proxy/internal/passkeys/auth-template.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/proxy/internal/passkeys/auth-template.html b/proxy/internal/passkeys/auth-template.html index 516bb2e..343d0b9 100644 --- a/proxy/internal/passkeys/auth-template.html +++ b/proxy/internal/passkeys/auth-template.html @@ -45,19 +45,19 @@
🛂
Authentication required to access
-
+
Continue
{{- end }} {{- if eq .Mode "RefreshID" }}
{{.Email}}
-
Refresh ID
+
Refresh ID
{{- end }} {{- if eq .Mode "RegisterNewID" }}
{{.Email}}
{{- if .IsAllowed }} {{- if .IsRegistered }}
Already Registered
-
Continue
+
Continue
{{- else }}
Register This Identity
{{- end }} From c2c3c8d578e8377e2e84c2399b1e4e755d76ea7d Mon Sep 17 00:00:00 2001 From: Robin Thellend Date: Thu, 14 Nov 2024 08:34:01 -0800 Subject: [PATCH 03/11] Improve refresh id flow --- proxy/internal/idp/idp.go | 51 ++++++++++++++++++++++ proxy/internal/oidc/client.go | 11 ++++- proxy/internal/passkeys/auth-template.html | 2 +- proxy/internal/passkeys/manager.go | 18 ++++++-- proxy/internal/passkeys/webauthn.js | 4 +- proxy/internal/saml/saml.go | 4 +- proxy/proxy.go | 3 +- 7 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 proxy/internal/idp/idp.go diff --git a/proxy/internal/idp/idp.go b/proxy/internal/idp/idp.go new file mode 100644 index 0000000..1c145f3 --- /dev/null +++ b/proxy/internal/idp/idp.go @@ -0,0 +1,51 @@ +// MIT License +// +// Copyright (c) 2024 TTBT Enterprises LLC +// Copyright (c) 2024 Robin Thellend +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package idp + +type LoginOptions struct { + LoginHint string + SelectAccount bool +} + +type Option func(*LoginOptions) + +func WithLoginHint(v string) Option { + return func(o *LoginOptions) { + o.LoginHint = v + } +} + +func WithSelectAccount(v bool) Option { + return func(o *LoginOptions) { + o.SelectAccount = v + } +} + +func ApplyOptions(opts []Option) LoginOptions { + var lo LoginOptions + for _, opt := range opts { + opt(&lo) + } + return lo +} diff --git a/proxy/internal/oidc/client.go b/proxy/internal/oidc/client.go index 4dd25cb..1dcbbca 100644 --- a/proxy/internal/oidc/client.go +++ b/proxy/internal/oidc/client.go @@ -38,6 +38,8 @@ import ( "time" jwt "github.com/golang-jwt/jwt/v5" + + "github.com/c2FmZQ/tlsproxy/proxy/internal/idp" ) // Config contains the parameters of an OIDC provider. @@ -144,7 +146,8 @@ func New(cfg Config, er EventRecorder, cm CookieManager) (*ProviderClient, error return p, nil } -func (p *ProviderClient) RequestLogin(w http.ResponseWriter, req *http.Request, originalURL string) { +func (p *ProviderClient) RequestLogin(w http.ResponseWriter, req *http.Request, originalURL string, opts ...idp.Option) { + loginOptions := idp.ApplyOptions(opts) ou, err := url.Parse(originalURL) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -187,6 +190,12 @@ func (p *ProviderClient) RequestLogin(w http.ResponseWriter, req *http.Request, if p.cfg.HostedDomain != "" { ep += "&hd=" + url.QueryEscape(p.cfg.HostedDomain) } + if loginOptions.LoginHint != "" { + ep += "&login_hint=" + url.QueryEscape(loginOptions.LoginHint) + } + if loginOptions.SelectAccount { + ep += "&prompt=select_account" + } p.cm.SetNonce(w, nonceStr) http.Redirect(w, req, ep, http.StatusFound) p.er.Record("oidc auth request") diff --git a/proxy/internal/passkeys/auth-template.html b/proxy/internal/passkeys/auth-template.html index 343d0b9..75667b6 100644 --- a/proxy/internal/passkeys/auth-template.html +++ b/proxy/internal/passkeys/auth-template.html @@ -37,7 +37,7 @@ Register New Identity {{- end }} {{- if ne .Mode "Login" }} - Switch Account + Switch Account {{- end }}
diff --git a/proxy/internal/passkeys/manager.go b/proxy/internal/passkeys/manager.go index 58b8ed6..cbb2a06 100644 --- a/proxy/internal/passkeys/manager.go +++ b/proxy/internal/passkeys/manager.go @@ -52,6 +52,7 @@ import ( jwt "github.com/golang-jwt/jwt/v5" "github.com/c2FmZQ/tlsproxy/proxy/internal/cookiemanager" + "github.com/c2FmZQ/tlsproxy/proxy/internal/idp" "github.com/c2FmZQ/tlsproxy/proxy/internal/tokenmanager" ) @@ -103,7 +104,7 @@ func (defaultLogger) Errorf(format string, args ...any) { type Config struct { Store *storage.Storage Other interface { - RequestLogin(w http.ResponseWriter, req *http.Request, origURL string) + RequestLogin(w http.ResponseWriter, req *http.Request, origURL string, opts ...idp.Option) } RefreshInterval time.Duration Endpoint string @@ -205,7 +206,7 @@ func (m *Manager) ServeWellKnown(w http.ResponseWriter, req *http.Request) { w.Write(content) } -func (m *Manager) RequestLogin(w http.ResponseWriter, req *http.Request, origURL string) { +func (m *Manager) RequestLogin(w http.ResponseWriter, req *http.Request, origURL string, opts ...idp.Option) { m.cfg.EventRecorder.Record("passkey auth request") n := make([]byte, 16) @@ -290,11 +291,13 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { redirectToken := req.Form.Get("redirect") if redirectToken == "" { + m.cfg.Logger.Errorf("ERR redirect not set") http.Error(w, "invalid request", http.StatusBadRequest) return } originalURL, err := m.cfg.TokenManager.ValidateURLToken(w, req, redirectToken) if err != nil { + m.cfg.Logger.Errorf("ERR redirect token: %v", err) http.Error(w, "invalid or expired request", http.StatusBadRequest) return } @@ -305,7 +308,7 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { case "RegisterNewID", "RefreshID": req.URL.Scheme = "https" req.URL.Host = req.Host - m.cfg.Other.RequestLogin(w, req, req.URL.String()) + m.cfg.Other.RequestLogin(w, req, req.URL.String(), idp.WithLoginHint(req.Form.Get("email")), idp.WithSelectAccount(mode == "RegisterNewID")) return case "AttestationOptions", "AddKey": http.Error(w, "internal error", http.StatusInternalServerError) @@ -408,6 +411,8 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { u := req.URL args := u.Query() args.Set("get", "RefreshID") + email, _ := claims["email"].(string) + args.Set("email", email) u.Scheme = "https" u.Host = req.Host u.RawQuery = args.Encode() @@ -528,7 +533,12 @@ func (m *Manager) ManageKeys(w http.ResponseWriter, req *http.Request) { } hh := sha256.Sum256([]byte(req.Host)) if claims == nil || claims["hhash"] != hex.EncodeToString(hh[:]) || time.Since(iat) > 10*time.Minute { - m.RequestLogin(w, req, here) + var opts []idp.Option + if claims != nil { + email, _ := claims["email"].(string) + opts = append(opts, idp.WithLoginHint(email)) + } + m.RequestLogin(w, req, here, opts...) return } mode := req.Form.Get("get") diff --git a/proxy/internal/passkeys/webauthn.js b/proxy/internal/passkeys/webauthn.js index 355d5fa..e9af68e 100644 --- a/proxy/internal/passkeys/webauthn.js +++ b/proxy/internal/passkeys/webauthn.js @@ -156,9 +156,7 @@ function loginWithPasskey(token, loginId) { window.location.reload(); } } else if (r.result === 'refresh') { - if (window.confirm(r.email + ' ID must be refreshed')) { - window.location = r.url; - } + window.location = r.url; } }) .catch(err => { diff --git a/proxy/internal/saml/saml.go b/proxy/internal/saml/saml.go index 88b047f..c6fa425 100644 --- a/proxy/internal/saml/saml.go +++ b/proxy/internal/saml/saml.go @@ -43,6 +43,8 @@ import ( "github.com/beevik/etree" dsig "github.com/russellhaering/goxmldsig" + + "github.com/c2FmZQ/tlsproxy/proxy/internal/idp" ) // http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf @@ -103,7 +105,7 @@ func New(cfg Config, er EventRecorder, cm CookieManager) (*Provider, error) { return p, nil } -func (p *Provider) RequestLogin(w http.ResponseWriter, req *http.Request, origURL string) { +func (p *Provider) RequestLogin(w http.ResponseWriter, req *http.Request, origURL string, opts ...idp.Option) { ou, err := url.Parse(origURL) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/proxy/proxy.go b/proxy/proxy.go index 8f8ad5c..988fc0d 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -64,6 +64,7 @@ import ( "github.com/c2FmZQ/tlsproxy/certmanager" "github.com/c2FmZQ/tlsproxy/proxy/internal/cookiemanager" "github.com/c2FmZQ/tlsproxy/proxy/internal/counter" + "github.com/c2FmZQ/tlsproxy/proxy/internal/idp" "github.com/c2FmZQ/tlsproxy/proxy/internal/netw" "github.com/c2FmZQ/tlsproxy/proxy/internal/ocspcache" "github.com/c2FmZQ/tlsproxy/proxy/internal/oidc" @@ -159,7 +160,7 @@ func (er eventRecorder) Record(s string) { } type identityProvider interface { - RequestLogin(w http.ResponseWriter, req *http.Request, origURL string) + RequestLogin(w http.ResponseWriter, req *http.Request, origURL string, opts ...idp.Option) HandleCallback(w http.ResponseWriter, req *http.Request) } From 35427cca19143945486c944173ad9c4d51d103a7 Mon Sep 17 00:00:00 2001 From: Robin Thellend Date: Thu, 14 Nov 2024 10:32:33 -0800 Subject: [PATCH 04/11] Set email field when value is known --- proxy/backend-sso.go | 27 +++++++++++++++----- proxy/internal/passkeys/auth-template.html | 2 +- proxy/internal/passkeys/manager.go | 22 +++++++++++++--- proxy/internal/tokenmanager/urltoken.go | 26 +++++++++++-------- proxy/internal/tokenmanager/urltoken_test.go | 6 ++--- 5 files changed, 57 insertions(+), 26 deletions(-) diff --git a/proxy/backend-sso.go b/proxy/backend-sso.go index 8a04f78..48c43e8 100644 --- a/proxy/backend-sso.go +++ b/proxy/backend-sso.go @@ -39,6 +39,7 @@ import ( jwt "github.com/golang-jwt/jwt/v5" "github.com/c2FmZQ/tlsproxy/proxy/internal/cookiemanager" + "github.com/c2FmZQ/tlsproxy/proxy/internal/idp" "github.com/c2FmZQ/tlsproxy/proxy/internal/passkeys" "github.com/c2FmZQ/tlsproxy/proxy/internal/tokenmanager" ) @@ -188,7 +189,7 @@ func (be *Backend) serveSSOStatus(w http.ResponseWriter, req *http.Request) { } req.URL.Scheme = "https" req.URL.Host = req.Host - token, _, err := be.tm.URLToken(w, req, req.URL) + token, _, err := be.tm.URLToken(w, req, req.URL, nil) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return @@ -204,12 +205,16 @@ func (be *Backend) serveLogin(w http.ResponseWriter, req *http.Request) { http.Error(w, "invalid request", http.StatusBadRequest) return } - url, err := be.tm.ValidateURLToken(w, req, tok) + url, claims, err := be.tm.ValidateURLToken(w, req, tok) if err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } - be.SSO.p.RequestLogin(w, req, url.String()) + var email string + if e, ok := claims["email"].(string); ok { + email = e + } + be.SSO.p.RequestLogin(w, req, url.String(), idp.WithLoginHint(email)) } func (be *Backend) serveLogout(w http.ResponseWriter, req *http.Request) { @@ -218,12 +223,12 @@ func (be *Backend) serveLogout(w http.ResponseWriter, req *http.Request) { } req.ParseForm() if tokenStr := req.Form.Get("u"); tokenStr != "" { - url, err := be.tm.ValidateURLToken(w, req, tokenStr) + url, _, err := be.tm.ValidateURLToken(w, req, tokenStr) if err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } - be.SSO.p.RequestLogin(w, req, url.String()) + be.SSO.p.RequestLogin(w, req, url.String(), idp.WithSelectAccount(true)) return } logoutTemplate.Execute(w, nil) @@ -236,7 +241,7 @@ func (be *Backend) servePermissionDenied(w http.ResponseWriter, req *http.Reques } req.URL.Scheme = "https" req.URL.Host = req.Host - token, url, err := be.tm.URLToken(w, req, req.URL) + token, url, err := be.tm.URLToken(w, req, req.URL, nil) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return @@ -284,7 +289,15 @@ func (be *Backend) enforceSSOPolicy(w http.ResponseWriter, req *http.Request) bo } req.URL.Scheme = "https" req.URL.Host = req.Host - token, url, err := be.tm.URLToken(w, req, req.URL) + var extra map[string]any + if claims != nil { + if email, ok := claims["email"].(string); ok { + extra = map[string]any{ + "email": email, + } + } + } + token, url, err := be.tm.URLToken(w, req, req.URL, extra) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return false diff --git a/proxy/internal/passkeys/auth-template.html b/proxy/internal/passkeys/auth-template.html index 75667b6..abbbca4 100644 --- a/proxy/internal/passkeys/auth-template.html +++ b/proxy/internal/passkeys/auth-template.html @@ -45,7 +45,7 @@
🛂
Authentication required to access
-
+
{{- end }} {{- if eq .Mode "RefreshID" }} diff --git a/proxy/internal/passkeys/manager.go b/proxy/internal/passkeys/manager.go index cbb2a06..0a59dcd 100644 --- a/proxy/internal/passkeys/manager.go +++ b/proxy/internal/passkeys/manager.go @@ -160,6 +160,7 @@ type challenge struct { type nonceData struct { created time.Time origURL *url.URL + opts idp.LoginOptions } func (m *Manager) SetACL(acl *[]string) { @@ -226,6 +227,7 @@ func (m *Manager) RequestLogin(w http.ResponseWriter, req *http.Request, origURL m.nonces[nonce] = &nonceData{ created: time.Now().UTC(), origURL: ou, + opts: idp.ApplyOptions(opts), } m.noncesMu.Unlock() @@ -269,7 +271,7 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { m.noncesMu.Unlock() if ok { - token, _, err := m.cfg.TokenManager.URLToken(w, req, nData.origURL) + token, _, err := m.cfg.TokenManager.URLToken(w, req, nData.origURL, map[string]any{"email": nData.opts.LoginHint}) if err != nil { m.cfg.Logger.Errorf("ERR %q: %v", nData.origURL, err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -295,7 +297,7 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { http.Error(w, "invalid request", http.StatusBadRequest) return } - originalURL, err := m.cfg.TokenManager.ValidateURLToken(w, req, redirectToken) + originalURL, redirectClaims, err := m.cfg.TokenManager.ValidateURLToken(w, req, redirectToken) if err != nil { m.cfg.Logger.Errorf("ERR redirect token: %v", err) http.Error(w, "invalid or expired request", http.StatusBadRequest) @@ -308,7 +310,18 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { case "RegisterNewID", "RefreshID": req.URL.Scheme = "https" req.URL.Host = req.Host - m.cfg.Other.RequestLogin(w, req, req.URL.String(), idp.WithLoginHint(req.Form.Get("email")), idp.WithSelectAccount(mode == "RegisterNewID")) + var opts []idp.Option + if mode == "RefreshID" { + email, _ := redirectClaims["email"].(string) + if email == "" { + email = req.Form.Get("email") + } + opts = append(opts, idp.WithLoginHint(email)) + } + if mode == "RegisterNewID" { + opts = append(opts, idp.WithSelectAccount(true)) + } + m.cfg.Other.RequestLogin(w, req, req.URL.String(), opts...) return case "AttestationOptions", "AddKey": http.Error(w, "internal error", http.StatusInternalServerError) @@ -341,6 +354,8 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { data.Email, _ = token.Claims.(jwt.MapClaims)["email"].(string) data.IsAllowed = m.subjectIsAllowed(data.Email) data.IsRegistered = m.subjectIsRegistered(data.Email) + } else { + data.Email, _ = redirectClaims["email"].(string) } w.Header().Set("X-Frame-Options", "DENY") authTemplate.Execute(w, data) @@ -419,7 +434,6 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { w.Header().Set("content-type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "result": "refresh", - "email": claims["email"], "url": u.String(), }) return diff --git a/proxy/internal/tokenmanager/urltoken.go b/proxy/internal/tokenmanager/urltoken.go index 6f56bea..861af41 100644 --- a/proxy/internal/tokenmanager/urltoken.go +++ b/proxy/internal/tokenmanager/urltoken.go @@ -62,7 +62,7 @@ func sessionID(w http.ResponseWriter, req *http.Request) string { } // URLToken returns a signed token for URL u in the context of request req. -func (tm *TokenManager) URLToken(w http.ResponseWriter, req *http.Request, u *url.URL) (string, string, error) { +func (tm *TokenManager) URLToken(w http.ResponseWriter, req *http.Request, u *url.URL, extra map[string]any) (string, string, error) { sid := sessionID(w, req) realHost := u.Host if h, err := idna.Lookup.ToUnicode(u.Hostname()); err == nil { @@ -70,31 +70,35 @@ func (tm *TokenManager) URLToken(w http.ResponseWriter, req *http.Request, u *ur } displayURL := u.String() u.Host = realHost - token, err := tm.CreateToken(jwt.MapClaims{ - "url": u.String(), - "sid": sid, - }, "") + claims := make(jwt.MapClaims) + for k, v := range extra { + claims[k] = v + } + claims["url"] = u.String() + claims["sid"] = sid + token, err := tm.CreateToken(claims, "") return token, displayURL, err } // ValidateURLToken validates a signed token and returns the URL. The request // must on the same host as the one where the token was created. -func (tm *TokenManager) ValidateURLToken(w http.ResponseWriter, req *http.Request, token string) (*url.URL, error) { +func (tm *TokenManager) ValidateURLToken(w http.ResponseWriter, req *http.Request, token string) (*url.URL, jwt.MapClaims, error) { tok, err := tm.ValidateToken(token) if err != nil { - return nil, err + return nil, nil, err } c, ok := tok.Claims.(jwt.MapClaims) if !ok { - return nil, errors.New("invalid token") + return nil, nil, errors.New("invalid token") } if sid := sessionID(w, req); sid != c["sid"] { tm.logger.Errorf("ERR session ID mismatch %q != %q", sid, c["sid"]) - return nil, errors.New("invalid token") + return nil, nil, errors.New("invalid token") } u, ok := c["url"].(string) if !ok { - return nil, errors.New("invalid token") + return nil, nil, errors.New("invalid token") } - return url.Parse(u) + tokURL, err := url.Parse(u) + return tokURL, c, err } diff --git a/proxy/internal/tokenmanager/urltoken_test.go b/proxy/internal/tokenmanager/urltoken_test.go index 43fd6ea..3831b68 100644 --- a/proxy/internal/tokenmanager/urltoken_test.go +++ b/proxy/internal/tokenmanager/urltoken_test.go @@ -45,7 +45,7 @@ func TestURLToken(t *testing.T) { req := httptest.NewRequest("GET", "https://example.com/foo/bar", nil) w := httptest.NewRecorder() - tok, displayURL, err := tm.URLToken(w, req, req.URL) + tok, displayURL, err := tm.URLToken(w, req, req.URL, nil) if err != nil { t.Errorf("URLToken() err = %v", err) } @@ -54,13 +54,13 @@ func TestURLToken(t *testing.T) { } // Wrong session id - if _, err := tm.ValidateURLToken(w, req, tok); err == nil { + if _, _, err := tm.ValidateURLToken(w, req, tok); err == nil { t.Fatal("ValidateURLToken should fail") } // Correct session id req.Header.Set("cookie", w.Header().Get("set-cookie")) - u, err := tm.ValidateURLToken(w, req, tok) + u, _, err := tm.ValidateURLToken(w, req, tok) if err != nil { t.Errorf("ValidateURLToken err = %v", err) } From 342bda9a9139a92774c721704a75e87de4e18bb6 Mon Sep 17 00:00:00 2001 From: Robin Thellend Date: Thu, 14 Nov 2024 10:55:53 -0800 Subject: [PATCH 05/11] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f6ea7..56b66e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## next +### :star: Feature improvements + +* Improve passkey authentication to support WebAuthn devices without discoverable credentials. + ### :wrench: Misc * Update go dependencies: From 1732d45aec0e3b1394fc993def05e0ea7b286cc9 Mon Sep 17 00:00:00 2001 From: Robin Thellend Date: Thu, 14 Nov 2024 12:17:07 -0800 Subject: [PATCH 06/11] tweak --- proxy/internal/idp/idp.go | 16 ++++++++++++---- proxy/internal/oidc/client.go | 6 +++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/proxy/internal/idp/idp.go b/proxy/internal/idp/idp.go index 1c145f3..4dd48d7 100644 --- a/proxy/internal/idp/idp.go +++ b/proxy/internal/idp/idp.go @@ -24,21 +24,29 @@ package idp type LoginOptions struct { - LoginHint string - SelectAccount bool + loginHint string + selectAccount bool +} + +func (o LoginOptions) LoginHint() string { + return o.loginHint +} + +func (o LoginOptions) SelectAccount() bool { + return o.selectAccount } type Option func(*LoginOptions) func WithLoginHint(v string) Option { return func(o *LoginOptions) { - o.LoginHint = v + o.loginHint = v } } func WithSelectAccount(v bool) Option { return func(o *LoginOptions) { - o.SelectAccount = v + o.selectAccount = v } } diff --git a/proxy/internal/oidc/client.go b/proxy/internal/oidc/client.go index 1dcbbca..872b074 100644 --- a/proxy/internal/oidc/client.go +++ b/proxy/internal/oidc/client.go @@ -190,10 +190,10 @@ func (p *ProviderClient) RequestLogin(w http.ResponseWriter, req *http.Request, if p.cfg.HostedDomain != "" { ep += "&hd=" + url.QueryEscape(p.cfg.HostedDomain) } - if loginOptions.LoginHint != "" { - ep += "&login_hint=" + url.QueryEscape(loginOptions.LoginHint) + if hint := loginOptions.LoginHint(); hint != "" { + ep += "&login_hint=" + url.QueryEscape(hint) } - if loginOptions.SelectAccount { + if loginOptions.SelectAccount() { ep += "&prompt=select_account" } p.cm.SetNonce(w, nonceStr) From 0d562cffc17a70d922f89e4ad42557a7861f5611 Mon Sep 17 00:00:00 2001 From: Robin Thellend Date: Thu, 14 Nov 2024 12:31:14 -0800 Subject: [PATCH 07/11] more tweaks --- proxy/internal/passkeys/manager.go | 2 +- proxy/metrics-template.html | 11 ++++++++++- proxy/metrics.go | 8 ++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/proxy/internal/passkeys/manager.go b/proxy/internal/passkeys/manager.go index 0a59dcd..8854767 100644 --- a/proxy/internal/passkeys/manager.go +++ b/proxy/internal/passkeys/manager.go @@ -271,7 +271,7 @@ func (m *Manager) HandleCallback(w http.ResponseWriter, req *http.Request) { m.noncesMu.Unlock() if ok { - token, _, err := m.cfg.TokenManager.URLToken(w, req, nData.origURL, map[string]any{"email": nData.opts.LoginHint}) + token, _, err := m.cfg.TokenManager.URLToken(w, req, nData.origURL, map[string]any{"email": nData.opts.LoginHint()}) if err != nil { m.cfg.Logger.Errorf("ERR %q: %v", nData.origURL, err) http.Error(w, "internal error", http.StatusInternalServerError) diff --git a/proxy/metrics-template.html b/proxy/metrics-template.html index 26f3c43..5ae04e4 100644 --- a/proxy/metrics-template.html +++ b/proxy/metrics-template.html @@ -99,10 +99,14 @@ .table > div.row:nth-child(even) > div { background-color: #f8f8ff; } - .group > div:nth-child(even) { background-color: #f8f8ff; } +#sso { + position: absolute; + right: 1rem; + top: 1rem; +}