Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add authentication with passkeys #18

Merged
merged 12 commits into from
Oct 7, 2023
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
2 changes: 1 addition & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '>=1.21.1'
go-version: '>=1.21.2'
- name: Build
run: go build ./...
- name: Run go vet
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '>=1.21.1'
go-version: '>=1.21.2'
- name: Build
run: go build ./...
- name: Run go vet
Expand All @@ -39,7 +39,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '>=1.21.1'
go-version: '>=1.21.2'
- name: Build release binaries
run: ./build-release-binaries.sh
- name: Create release
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.21.1-alpine3.18 AS build
FROM golang:1.21.2-alpine3.18 AS build
MAINTAINER info@c2fmzq.org
RUN apk update && apk upgrade
RUN apk add ca-certificates
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Overview of features:
* [x] Terminate _TCP_ connections, and forward the TLS connection to any TLS server (passthrough). The proxy doesn't see the plaintext.
* [x] Terminate HTTPS connections, and forward the requests to HTTP or HTTPS servers (http/1 only, not recommended with c2fmzq-server).
* [x] TLS client authentication & authorization (when the proxy terminates the TLS connections).
* [x] User authentication with OpenID Connect and SAML (for HTTP and HTTPS connections). Optionally issue JSON Web Tokens (JWT) to authenticated users to use with the backend services and/or run a local OpenID Connect server for backend services.
* [x] User authentication with OpenID Connect, SAML, and/or passkeys (for HTTP and HTTPS connections). Optionally issue JSON Web Tokens (JWT) to authenticated users to use with the backend services and/or run a local OpenID Connect server for backend services.
* [x] Access control by IP address.
* [x] Routing based on Server Name Indication (SNI), with optional default route when SNI isn't used.
* [x] Simple round-robin load balancing between servers.
Expand Down
2 changes: 1 addition & 1 deletion examples/backend/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/c2FmZQ/tlsproxy/examples/backend

go 1.21.1
go 1.21.2

require (
github.com/blend/go-sdk v1.20220411.3
Expand Down
41 changes: 39 additions & 2 deletions examples/sso/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# User authentication with OpenID Connect and SAML
# User authentication with OpenID Connect, SAML and/or Passkeys

TLSPROXY can be configured to authenticate users with OpenID Connect and SAML identity providers.
TLSPROXY can be configured to authenticate users with OpenID Connect and SAML identity providers. Another option is to use Passkeys for password-less user authentication. To configure Passkeys, users still need to authenticate once with OpenID Connect or SAML, but then authentication is done exclusively with Passkeys.

OpenID Connect has been tested with Google and Facebook as identity providers.
SAML has been tested with Google Workspace.
Expand Down Expand Up @@ -102,3 +102,40 @@ backends:
- bob@EXAMPLE.COM
- "@EXAMPLE.COM" <--- allows anyone from EXAMPLE.COM
```

## Passkeys with initial authentication with Google OpenID Connect

```yaml
oidc:
- name: google
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth"
tokenEndpoint: "https://oauth2.googleapis.com/token"
redirectUrl: "https://login.EXAMPLE.COM/oidc/google"
clientId: "<YOUR CLIENT ID>"
clientSecret: "<YOUR CLIENT SECRET>"
domain: "EXAMPLE.COM"

passkey:
- name: "passkey"
identityProvider: "google"
endpoint: "https://login.EXAMPLE.COM/passkey"
domain: "EXAMPLE.COM"

backends:
- serverNames:
- login.EXAMPLE.COM
mode: https

- serverNames:
- www.EXAMPLE.COM
mode: http
addresses:
- 192.168.1.1:80
sso:
provider: passkey
acl:
- alice@EXAMPLE.COM
- bob@EXAMPLE.COM
- "@EXAMPLE.COM" <--- allows anyone from EXAMPLE.COM
```

2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.21
require (
github.com/beevik/etree v1.2.0
github.com/c2FmZQ/storage v0.1.0
github.com/fxamacker/cbor/v2 v2.5.0
github.com/go-test/deep v1.1.0
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/russellhaering/goxmldsig v1.4.0
Expand All @@ -17,6 +18,7 @@ require (
require (
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/text v0.13.0 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
Expand Down Expand Up @@ -34,6 +36,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
Expand Down
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@

// tlsproxy is a simple TLS terminating proxy that uses Let's Encrypt to provide
// TLS encryption for any TCP and HTTP servers.
//
// It can also act as a reverse HTTP proxy with optional user authentication
// with SAML, OpenID Connect, and/or passkeys.
package main

import (
Expand Down
34 changes: 26 additions & 8 deletions proxy/backend-sso.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ package proxy

import (
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"fmt"
"html/template"
"log"
Expand Down Expand Up @@ -59,6 +61,8 @@ var (
//go:embed sso-status-template.html
ssoStatusEmbed string
ssoStatusTemplate *template.Template
//go:embed style.css
styleEmbed []byte
)

func init() {
Expand All @@ -67,9 +71,9 @@ func init() {
ssoStatusTemplate = template.Must(template.New("sso-status").Parse(ssoStatusEmbed))
}

func claimsFromCtx(ctx context.Context) jwt.Claims {
func claimsFromCtx(ctx context.Context) jwt.MapClaims {
if v := ctx.Value(authCtxKey); v != nil {
return v.(jwt.Claims)
return v.(jwt.MapClaims)
}
return nil
}
Expand All @@ -92,8 +96,6 @@ func (be *Backend) userAuthentication(next http.Handler) http.Handler {
}
}
}
// Filter out the tlsproxy auth cookie.
cookiemanager.FilterOutAuthTokenCookie(req)
next.ServeHTTP(w, req)
})
}
Expand Down Expand Up @@ -139,11 +141,24 @@ func (be *Backend) checkCookies(w http.ResponseWriter, req *http.Request) (jwt.C
return nil, false
}

func (be *Backend) serveSSOStatus(w http.ResponseWriter, req *http.Request) {
var claims jwt.MapClaims
if c := claimsFromCtx(req.Context()); c != nil {
claims, _ = c.(jwt.MapClaims)
func (be *Backend) serveSSOStyle(w http.ResponseWriter, req *http.Request) {
sum := sha256.Sum256(styleEmbed)
etag := `"` + hex.EncodeToString(sum[:]) + `"`

w.Header().Set("Content-Type", "text/css")
w.Header().Set("Cache-Control", "public")
w.Header().Set("Etag", etag)

if e := req.Header.Get("If-None-Match"); e == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
w.Write(styleEmbed)
}

func (be *Backend) serveSSOStatus(w http.ResponseWriter, req *http.Request) {
claims := claimsFromCtx(req.Context())
var keys []string
for k := range claims {
keys = append(keys, k)
Expand Down Expand Up @@ -239,6 +254,9 @@ func (be *Backend) servePermissionDenied(w http.ResponseWriter, req *http.Reques
}

func (be *Backend) enforceSSOPolicy(w http.ResponseWriter, req *http.Request) bool {
// Filter out the tlsproxy auth cookie.
cookiemanager.FilterOutAuthTokenCookie(req)

if be.SSO == nil || !pathMatches(be.SSO.Paths, req.URL.Path) {
return true
}
Expand Down
6 changes: 6 additions & 0 deletions proxy/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ func (be *Backend) localHandlersAndAuthz(next http.Handler) http.Handler {
h.handler.ServeHTTP(w, req)
return
}
if !exists {
if _, ok := be.localHandlers[req.URL.Path+"/"]; ok {
http.Redirect(w, req, req.URL.Path+"/", http.StatusMovedPermanently)
return
}
}
if next == nil {
http.NotFound(w, req)
return
Expand Down
46 changes: 46 additions & 0 deletions proxy/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ type Config struct {
OIDCProviders []*ConfigOIDC `yaml:"oidc,omitempty"`
// SAMLProviders is the list of SAML providers.
SAMLProviders []*ConfigSAML `yaml:"saml,omitempty"`
// PasskeyProviders are identity providers that use OIDC or SAML for
// the first authentication and to configure passkeys, and then rely
// exclusively on passkeys.
PasskeyProviders []*ConfigPasskey `yaml:"passkey,omitempty"`
// Email is optionally included in the requests to letsencrypt.
Email string `yaml:"email,omitempty"`
// MaxOpen is the maximum number of open incoming connections.
Expand Down Expand Up @@ -311,6 +315,7 @@ type ConfigOIDC struct {

// ConfigSAML contains the parameters of a SAML identity provider.
type ConfigSAML struct {
// Name is the name of the provider. It is used internally only.
Name string `yaml:"name"`
SSOURL string `yaml:"ssoUrl"`
EntityID string `yaml:"entityId"`
Expand All @@ -321,6 +326,22 @@ type ConfigSAML struct {
Domain string `yaml:"domain,omitempty"`
}

// ConfigPasskey contains the parameters of a Passkey manager.
type ConfigPasskey struct {
// Name is the name of the provider. It is used internally only.
Name string `yaml:"name"`
// IdentityProvider is the name of another identity provider that will
// be used to authenticate the user before registering their first
// passkey.
IdentityProvider string `yaml:"identityProvider"`
// Endpoint is a URL on this proxy that will handle the passkey
// authentication.
Endpoint string `yaml:"endpoint"`
// Domain, if set, determine the domain where the user identities will
// be valid.
Domain string `yaml:"domain,omitempty"`
}

// BackendSSO specifies the identity parameters to use for a backend.
type BackendSSO struct {
// Provider is the the name of an identity provider defined in
Expand Down Expand Up @@ -507,6 +528,31 @@ func (cfg *Config) Check() error {
}
}
}
for i, pp := range cfg.PasskeyProviders {
if identityProviders[pp.Name] {
return fmt.Errorf("passkey[%d].Name: duplicate provider name %q", i, pp.Name)
}
identityProviders[pp.Name] = true
if pp.Endpoint == "" {
return fmt.Errorf("passkey[%d].Endpoint must be set", i)
}
if pp.IdentityProvider == "" {
return fmt.Errorf("passkey[%d].IdentityProvider must be set", i)
}
if _, ok := identityProviders[pp.IdentityProvider]; !ok {
return fmt.Errorf("passkey[%d].IdentityProvider has unexpected value %q", i, pp.IdentityProvider)
}
if pp.Domain != "" {
u, _ := url.Parse(pp.Endpoint)
host := u.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if !strings.HasSuffix(host, pp.Domain) {
return fmt.Errorf("passkey[%d].Domain %q must be part of Endpoint", i, pp.Domain)
}
}
}

serverNames := make(map[string]bool)
for i, be := range cfg.Backends {
Expand Down
8 changes: 7 additions & 1 deletion proxy/internal/cookiemanager/cookiemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ func New(tm *tokenmanager.TokenManager, provider, domain, issuer string) *Cookie
}
}

func (cm *CookieManager) SetAuthTokenCookie(w http.ResponseWriter, userID, sessionID string, extraClaims map[string]string) error {
func (cm *CookieManager) SetAuthTokenCookie(w http.ResponseWriter, userID, sessionID string, extraClaims map[string]any) error {
if userID == "" {
return errors.New("userID cannot be empty")
}
now := time.Now().UTC()
claims := jwt.MapClaims{
"iat": now.Unix(),
Expand Down Expand Up @@ -161,6 +164,9 @@ func (cm *CookieManager) ValidateAuthTokenCookie(req *http.Request) (*jwt.Token,
if c, ok := tok.Claims.(jwt.MapClaims); !ok || c["proxyauth"] != cm.issuer || c["provider"] != cm.provider {
return nil, errors.New("invalid proxyauth or provider")
}
if sub, err := tok.Claims.GetSubject(); err != nil || sub == "" {
return nil, errors.New("invalid subject")
}
return tok, nil
}

Expand Down
4 changes: 2 additions & 2 deletions proxy/internal/oidc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ type Config struct {

// CookieManager is the interface to set and clear the auth token.
type CookieManager interface {
SetAuthTokenCookie(w http.ResponseWriter, userID, sessionID string, extraClaims map[string]string) error
SetAuthTokenCookie(w http.ResponseWriter, userID, sessionID string, extraClaims map[string]any) error
ClearCookies(w http.ResponseWriter) error
}

Expand Down Expand Up @@ -246,7 +246,7 @@ func (p *ProviderClient) HandleCallback(w http.ResponseWriter, req *http.Request
http.Error(w, "email not verified", http.StatusForbidden)
return
}
extraClaims := map[string]string{
extraClaims := map[string]any{
"source": claims.Issuer,
}
if claims.Name != "" {
Expand Down
11 changes: 3 additions & 8 deletions proxy/internal/oidc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ type ServerOptions struct {
TokenManager *tokenmanager.TokenManager
Issuer string
PathPrefix string
ClaimsFromCtx func(context.Context) jwt.Claims
ClaimsFromCtx func(context.Context) jwt.MapClaims
Clients []Client
RewriteRules []RewriteRule

Expand Down Expand Up @@ -280,22 +280,17 @@ func (s *ProviderServer) ServeAuthorization(w http.ResponseWriter, req *http.Req
claims["email_verified"] = true
sc += " email"
}
uc, ok := userClaims.(jwt.MapClaims)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if slices.Contains(scopes, "profile") {
for _, v := range []string{"name", "family_name", "given_name", "middle_name", "nickname", "preferred_username", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"} {
if vv := uc[v]; vv != nil {
if vv := userClaims[v]; vv != nil {
claims[v] = vv
}
}
sc += " profile"
}
claims["scope"] = sc

applyRewriteRules(s.opts.RewriteRules, uc, claims)
applyRewriteRules(s.opts.RewriteRules, userClaims, claims)

token, err := s.opts.TokenManager.CreateToken(claims, "RS256")
if err != nil {
Expand Down
Loading
Loading