mirror of
https://github.com/pomerium/pomerium.git
synced 2025-04-29 10:26:29 +02:00
webauthnutil: add helpers for webauthn (#2686)
* devices: add device protobuf types * webauthnutil: add helpers for webauthn
This commit is contained in:
parent
961bc8abb4
commit
1c445c426d
13 changed files with 872 additions and 2 deletions
6
go.mod
6
go.mod
|
@ -43,6 +43,7 @@ require (
|
|||
github.com/ory/dockertest/v3 v3.8.0
|
||||
github.com/peterbourgon/ff/v3 v3.1.2
|
||||
github.com/pomerium/csrf v1.7.0
|
||||
github.com/pomerium/webauthn v0.0.0-20211014213840-422c7ce1077f
|
||||
github.com/prometheus/client_golang v1.11.0
|
||||
github.com/prometheus/client_model v0.2.0
|
||||
github.com/prometheus/common v0.31.1
|
||||
|
@ -115,6 +116,7 @@ require (
|
|||
github.com/fatih/color v1.12.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.3.0 // indirect
|
||||
github.com/fzipp/gocyclo v0.3.1 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/go-critic/go-critic v0.5.6 // indirect
|
||||
|
@ -142,6 +144,7 @@ require (
|
|||
github.com/golangci/misspell v0.3.5 // indirect
|
||||
github.com/golangci/revgrep v0.0.0-20210208091834-cd28932614b5 // indirect
|
||||
github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4 // indirect
|
||||
github.com/google/go-tpm v0.3.2 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20210225214923-2e10b2664254 // indirect
|
||||
|
@ -227,6 +230,7 @@ require (
|
|||
github.com/ultraware/funlen v0.0.3 // indirect
|
||||
github.com/ultraware/whitespace v0.0.4 // indirect
|
||||
github.com/uudashr/gocognit v1.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
|
@ -242,7 +246,7 @@ require (
|
|||
google.golang.org/appengine v1.6.7 // indirect
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.22.0 // indirect
|
||||
gopkg.in/ini.v1 v1.63.2 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.5.1 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.2.1 // indirect
|
||||
mvdan.cc/gofumpt v0.1.1 // indirect
|
||||
|
|
16
go.sum
16
go.sum
|
@ -436,6 +436,8 @@ github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWp
|
|||
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
|
||||
github.com/fullstorydev/grpcurl v1.6.0/go.mod h1:ZQ+ayqbKMJNhzLmbpCiurTVlaK2M/3nqZCxaQ2Ze/sM=
|
||||
github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
|
||||
github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
||||
github.com/fzipp/gocyclo v0.3.1 h1:A9UeX3HJSXTBzvHzhqoYVuE0eAhe+aM8XBCCwsPMZOc=
|
||||
github.com/fzipp/gocyclo v0.3.1/go.mod h1:DJHO6AUmbdqj2ET4Z9iArSuwWgYDRryYt2wASxc7x3E=
|
||||
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
|
||||
|
@ -611,6 +613,12 @@ github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
|||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-jsonnet v0.17.0 h1:/9NIEfhK1NQRKl3sP2536b2+x5HnZMdql7x3yK/l8JY=
|
||||
github.com/google/go-jsonnet v0.17.0/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
|
||||
github.com/google/go-tpm v0.1.2-0.20190725015402-ae6dd98980d4/go.mod h1:H9HbmUG2YgV/PHITkO7p6wxEEj/v5nlsVWIwumwH2NI=
|
||||
github.com/google/go-tpm v0.3.0/go.mod h1:iVLWvrPp/bHeEkxTFi9WG6K9w0iy2yIszHwZGHPbzAw=
|
||||
github.com/google/go-tpm v0.3.2 h1:3iQQ2dlEf+1no7CLlfLPYzxhQy7j2G/emBqU5okydaw=
|
||||
github.com/google/go-tpm v0.3.2/go.mod h1:j71sMBTfp3X5jPHz852ZOfQMUOf65Gb/Th8pRmp7fvg=
|
||||
github.com/google/go-tpm-tools v0.0.0-20190906225433-1614c142f845/go.mod h1:AVfHadzbdzHo54inR2x1v640jdi1YSi3NauM2DUsxk0=
|
||||
github.com/google/go-tpm-tools v0.2.0/go.mod h1:npUd03rQ60lxN7tzeBJreG38RvWwme2N1reF/eeiBk4=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
|
@ -1044,6 +1052,8 @@ github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349 h1:Kq/3kL0k
|
|||
github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
|
||||
github.com/pomerium/csrf v1.7.0 h1:Qp4t6oyEod3svQtKfJZs589mdUTWKVf7q0PgCKYCshY=
|
||||
github.com/pomerium/csrf v1.7.0/go.mod h1:hAPZV47mEj2T9xFs+ysbum4l7SF1IdrryYaY6PdoIqw=
|
||||
github.com/pomerium/webauthn v0.0.0-20211014213840-422c7ce1077f h1:442shkoI4Oh4RHdzFaGma1t9Ji/T+8pfCxQQzmY5kj8=
|
||||
github.com/pomerium/webauthn v0.0.0-20211014213840-422c7ce1077f/go.mod h1:wgH3ualWdXu/qwbhOoSQedXzco+38Iz7qKKGCJcKPXg=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
|
||||
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
|
||||
|
@ -1275,6 +1285,8 @@ github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17
|
|||
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||
github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
|
@ -1575,6 +1587,7 @@ golang.org/x/sys v0.0.0-20201117170446-d9b008d0a637/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
@ -1914,8 +1927,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24
|
|||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w=
|
||||
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
|
|
61
pkg/webauthnutil/credential_storage.go
Normal file
61
pkg/webauthnutil/credential_storage.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/btcsuite/btcutil/base58"
|
||||
"github.com/pomerium/webauthn"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||
)
|
||||
|
||||
// CredentialStorage stores credentials in the databroker.
|
||||
type CredentialStorage struct {
|
||||
client databroker.DataBrokerServiceClient
|
||||
}
|
||||
|
||||
// NewCredentialStorage creates a new CredentialStorage.
|
||||
func NewCredentialStorage(client databroker.DataBrokerServiceClient) *CredentialStorage {
|
||||
return &CredentialStorage{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCredential gets a credential from the databroker.
|
||||
func (storage *CredentialStorage) GetCredential(
|
||||
ctx context.Context,
|
||||
credentialID []byte,
|
||||
) (*webauthn.Credential, error) {
|
||||
record, err := device.GetOwnerCredentialRecord(ctx, storage.client, credentialID)
|
||||
if status.Code(err) == codes.NotFound {
|
||||
return nil, webauthn.ErrCredentialNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &webauthn.Credential{
|
||||
ID: record.GetId(),
|
||||
OwnerID: record.GetOwnerId(),
|
||||
PublicKey: record.GetPublicKey(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetCredential sets the credential for the enrollment.
|
||||
func (storage *CredentialStorage) SetCredential(
|
||||
ctx context.Context,
|
||||
credential *webauthn.Credential,
|
||||
) error {
|
||||
record := &device.OwnerCredentialRecord{
|
||||
Id: credential.ID,
|
||||
OwnerId: credential.OwnerID,
|
||||
PublicKey: credential.PublicKey,
|
||||
}
|
||||
return device.PutOwnerCredentialRecord(ctx, storage.client, record)
|
||||
}
|
||||
|
||||
// GetDeviceCredentialID gets the device credential id from a public key credential id.
|
||||
func GetDeviceCredentialID(credentialID []byte) string {
|
||||
return base58.Encode(credentialID)
|
||||
}
|
60
pkg/webauthnutil/credential_storage_test.go
Normal file
60
pkg/webauthnutil/credential_storage_test.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/pomerium/webauthn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||
)
|
||||
|
||||
type mockDataBrokerServiceClient struct {
|
||||
databroker.DataBrokerServiceClient
|
||||
|
||||
get func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error)
|
||||
put func(ctx context.Context, in *databroker.PutRequest, opts ...grpc.CallOption) (*databroker.PutResponse, error)
|
||||
}
|
||||
|
||||
func (m mockDataBrokerServiceClient) Get(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
|
||||
return m.get(ctx, in, opts...)
|
||||
}
|
||||
|
||||
func (m mockDataBrokerServiceClient) Put(ctx context.Context, in *databroker.PutRequest, opts ...grpc.CallOption) (*databroker.PutResponse, error) {
|
||||
return m.put(ctx, in, opts...)
|
||||
}
|
||||
|
||||
func TestCredentialStorage(t *testing.T) {
|
||||
m := map[string]*databroker.Record{}
|
||||
client := &mockDataBrokerServiceClient{
|
||||
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
|
||||
record, ok := m[in.GetType()+"/"+in.GetId()]
|
||||
if !ok {
|
||||
return nil, status.Error(codes.NotFound, "record not found")
|
||||
}
|
||||
return &databroker.GetResponse{
|
||||
Record: record,
|
||||
}, nil
|
||||
},
|
||||
put: func(ctx context.Context, in *databroker.PutRequest, opts ...grpc.CallOption) (*databroker.PutResponse, error) {
|
||||
m[in.GetRecord().GetType()+"/"+in.GetRecord().GetId()] = in.GetRecord()
|
||||
return &databroker.PutResponse{
|
||||
Record: in.GetRecord(),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
storage := NewCredentialStorage(client)
|
||||
_, err := storage.GetCredential(context.Background(), []byte{0, 1, 2, 3, 4})
|
||||
assert.ErrorIs(t, err, webauthn.ErrCredentialNotFound)
|
||||
err = storage.SetCredential(context.Background(), &webauthn.Credential{
|
||||
ID: []byte{0, 1, 2, 3, 4},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
c, err := storage.GetCredential(context.Background(), []byte{0, 1, 2, 3, 4})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte{0, 1, 2, 3, 4}, c.ID)
|
||||
}
|
52
pkg/webauthnutil/device_type.go
Normal file
52
pkg/webauthnutil/device_type.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pomerium/webauthn/cose"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||
)
|
||||
|
||||
var predefinedDeviceTypes = map[string]*device.Type{
|
||||
"default": {
|
||||
Id: "default",
|
||||
Name: "default",
|
||||
Specifier: &device.Type_Webauthn{
|
||||
Webauthn: &device.Type_WebAuthn{
|
||||
Options: &device.WebAuthnOptions{
|
||||
Attestation: device.WebAuthnOptions_DIRECT.Enum(),
|
||||
AuthenticatorSelection: &device.WebAuthnOptions_AuthenticatorSelectionCriteria{
|
||||
UserVerification: device.WebAuthnOptions_USER_VERIFICATION_PREFERRED.Enum(),
|
||||
},
|
||||
PubKeyCredParams: []*device.WebAuthnOptions_PublicKeyCredentialParameters{
|
||||
{Type: device.WebAuthnOptions_PUBLIC_KEY, Alg: int64(cose.AlgorithmES256)},
|
||||
{Type: device.WebAuthnOptions_PUBLIC_KEY, Alg: int64(cose.AlgorithmRS256)},
|
||||
{Type: device.WebAuthnOptions_PUBLIC_KEY, Alg: int64(cose.AlgorithmRS1)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// GetDeviceType gets the device type from the databroker. If the device type does not exist in the databroker
|
||||
// a pre-defined device type may be returned.
|
||||
func GetDeviceType(
|
||||
ctx context.Context,
|
||||
client databroker.DataBrokerServiceClient,
|
||||
deviceTypeID string,
|
||||
) (*device.Type, error) {
|
||||
deviceType, err := device.GetType(ctx, client, deviceTypeID)
|
||||
if status.Code(err) == codes.NotFound {
|
||||
var ok bool
|
||||
deviceType, ok = predefinedDeviceTypes[deviceTypeID]
|
||||
if ok {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
return deviceType, err
|
||||
}
|
61
pkg/webauthnutil/device_type_test.go
Normal file
61
pkg/webauthnutil/device_type_test.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
|
||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||
)
|
||||
|
||||
func TestGetDeviceType(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("from databroker", func(t *testing.T) {
|
||||
client := &mockDataBrokerServiceClient{
|
||||
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
|
||||
assert.Equal(t, "type.googleapis.com/pomerium.device.Type", in.GetType())
|
||||
assert.Equal(t, "default", in.GetId())
|
||||
any, _ := anypb.New(&device.Type{
|
||||
Id: "default",
|
||||
Name: "Example",
|
||||
})
|
||||
return &databroker.GetResponse{
|
||||
Record: &databroker.Record{
|
||||
Type: in.GetType(),
|
||||
Id: in.GetId(),
|
||||
Data: any,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
deviceType, err := GetDeviceType(ctx, client, "default")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Example", deviceType.GetName())
|
||||
})
|
||||
t.Run("default", func(t *testing.T) {
|
||||
client := &mockDataBrokerServiceClient{
|
||||
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
|
||||
return nil, status.Error(codes.NotFound, "not found")
|
||||
},
|
||||
}
|
||||
deviceType, err := GetDeviceType(ctx, client, "default")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "default", deviceType.GetName())
|
||||
})
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
client := &mockDataBrokerServiceClient{
|
||||
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
|
||||
return nil, status.Error(codes.NotFound, "not found")
|
||||
},
|
||||
}
|
||||
_, err := GetDeviceType(ctx, client, "example")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
35
pkg/webauthnutil/enrollment_token.go
Normal file
35
pkg/webauthnutil/enrollment_token.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/pomerium/pomerium/pkg/cryptutil"
|
||||
)
|
||||
|
||||
// NewEnrollmentToken creates a new EnrollmentToken.
|
||||
func NewEnrollmentToken(key []byte, ttl time.Duration, deviceEnrollmentID string) (string, error) {
|
||||
id, err := uuid.Parse(deviceEnrollmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
secureToken := cryptutil.GenerateSecureToken(key, time.Now().Add(ttl), cryptutil.Token(id))
|
||||
return secureToken.String(), nil
|
||||
}
|
||||
|
||||
// ParseAndVerifyEnrollmentToken parses and verifies an enrollment token
|
||||
func ParseAndVerifyEnrollmentToken(key []byte, rawEnrollmentToken string) (string, error) {
|
||||
secureToken, ok := cryptutil.SecureTokenFromString(rawEnrollmentToken)
|
||||
if !ok {
|
||||
return "", cryptutil.ErrInvalid
|
||||
}
|
||||
|
||||
err := secureToken.Verify(key, time.Now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return secureToken.Token().UUID().String(), nil
|
||||
}
|
18
pkg/webauthnutil/enrollment_token_test.go
Normal file
18
pkg/webauthnutil/enrollment_token_test.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEnrollmentToken(t *testing.T) {
|
||||
key := []byte{1, 2, 3}
|
||||
deviceEnrollmentID := "19be0131-184e-4873-acab-2be79321c30b"
|
||||
token, err := NewEnrollmentToken(key, time.Second*30, deviceEnrollmentID)
|
||||
assert.NoError(t, err)
|
||||
id, err := ParseAndVerifyEnrollmentToken(key, token)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, deviceEnrollmentID, id)
|
||||
}
|
292
pkg/webauthnutil/options.go
Normal file
292
pkg/webauthnutil/options.go
Normal file
|
@ -0,0 +1,292 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pomerium/webauthn"
|
||||
"github.com/pomerium/webauthn/cose"
|
||||
|
||||
"github.com/pomerium/pomerium/pkg/cryptutil"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||
)
|
||||
|
||||
const (
|
||||
ceremonyTimeout = time.Minute * 15
|
||||
rpName = "Pomerium"
|
||||
)
|
||||
|
||||
// GenerateChallenge generates a new Challenge.
|
||||
func GenerateChallenge(key []byte, expiry time.Time) cryptutil.SecureToken {
|
||||
return cryptutil.GenerateSecureToken(key, expiry, cryptutil.NewRandomToken())
|
||||
}
|
||||
|
||||
// GenerateCreationOptions generates creation options for WebAuthn.
|
||||
func GenerateCreationOptions(
|
||||
key []byte,
|
||||
deviceType *device.Type,
|
||||
user *user.User,
|
||||
) *webauthn.PublicKeyCredentialCreationOptions {
|
||||
expiry := time.Now().Add(ceremonyTimeout)
|
||||
return newCreationOptions(
|
||||
GenerateChallenge(key, expiry).Bytes(),
|
||||
deviceType,
|
||||
user,
|
||||
)
|
||||
}
|
||||
|
||||
// GenerateRequestOptions generates request options for WebAuthn.
|
||||
func GenerateRequestOptions(
|
||||
key []byte,
|
||||
deviceType *device.Type,
|
||||
knownDeviceCredentials []*device.Credential,
|
||||
) *webauthn.PublicKeyCredentialRequestOptions {
|
||||
expiry := time.Now().Add(ceremonyTimeout)
|
||||
return newRequestOptions(
|
||||
GenerateChallenge(key, expiry).Bytes(),
|
||||
deviceType,
|
||||
knownDeviceCredentials,
|
||||
)
|
||||
}
|
||||
|
||||
// GetCreationOptionsForCredential gets the creation options for the public key creation credential. An error may be
|
||||
// returned if the challenge used to generate the credential is invalid.
|
||||
func GetCreationOptionsForCredential(
|
||||
key []byte,
|
||||
deviceType *device.Type,
|
||||
user *user.User,
|
||||
credential *webauthn.PublicKeyCreationCredential,
|
||||
) (*webauthn.PublicKeyCredentialCreationOptions, error) {
|
||||
clientData, err := credential.Response.UnmarshalClientData()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid client data: %w", err)
|
||||
}
|
||||
|
||||
rawChallenge, err := base64.RawURLEncoding.DecodeString(clientData.Challenge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid challenge: %w", err)
|
||||
}
|
||||
var challenge cryptutil.SecureToken
|
||||
copy(challenge[:], rawChallenge)
|
||||
|
||||
err = challenge.Verify(key, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCreationOptions(challenge.Bytes(), deviceType, user), nil
|
||||
}
|
||||
|
||||
// GetRequestOptionsForCredential gets the request options for the public key request credential. An error may be
|
||||
// returned if the challenge used to generate the credential is invalid.
|
||||
func GetRequestOptionsForCredential(
|
||||
key []byte,
|
||||
deviceType *device.Type,
|
||||
knownDeviceCredentials []*device.Credential,
|
||||
credential *webauthn.PublicKeyAssertionCredential,
|
||||
) (*webauthn.PublicKeyCredentialRequestOptions, error) {
|
||||
clientData, err := credential.Response.UnmarshalClientData()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid client data: %w", err)
|
||||
}
|
||||
|
||||
rawChallenge, err := base64.RawURLEncoding.DecodeString(clientData.Challenge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid challenge: %w", err)
|
||||
}
|
||||
var challenge cryptutil.SecureToken
|
||||
copy(challenge[:], rawChallenge)
|
||||
|
||||
err = challenge.Verify(key, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newRequestOptions(challenge.Bytes(), deviceType, knownDeviceCredentials), nil
|
||||
}
|
||||
|
||||
// newCreationOptions gets the creation options for WebAuthn with the provided challenge.
|
||||
func newCreationOptions(
|
||||
challenge []byte,
|
||||
deviceType *device.Type,
|
||||
user *user.User,
|
||||
) *webauthn.PublicKeyCredentialCreationOptions {
|
||||
options := &webauthn.PublicKeyCredentialCreationOptions{
|
||||
RP: webauthn.PublicKeyCredentialRPEntity{
|
||||
Name: rpName,
|
||||
},
|
||||
User: GetUserEntity(user),
|
||||
Challenge: challenge,
|
||||
Timeout: ceremonyTimeout,
|
||||
}
|
||||
|
||||
if deviceOptions := deviceType.GetWebauthn().GetOptions(); deviceOptions != nil {
|
||||
fillAllPublicKeyCredentialParameters(options, deviceOptions.GetPubKeyCredParams())
|
||||
fillAuthenticatorSelection(options, deviceOptions.GetAuthenticatorSelection())
|
||||
fillAttestationConveyance(options, deviceOptions.Attestation)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// newRequestOptions gets the request options for WebAuthn with the provided challenge.
|
||||
func newRequestOptions(
|
||||
challenge []byte,
|
||||
deviceType *device.Type,
|
||||
knownDeviceCredentials []*device.Credential,
|
||||
) *webauthn.PublicKeyCredentialRequestOptions {
|
||||
options := &webauthn.PublicKeyCredentialRequestOptions{
|
||||
Challenge: challenge,
|
||||
Timeout: ceremonyTimeout,
|
||||
}
|
||||
fillRequestUserVerificationRequirement(
|
||||
options,
|
||||
deviceType.GetWebauthn().GetOptions().GetAuthenticatorSelection().UserVerification,
|
||||
)
|
||||
for _, knownDeviceCredential := range knownDeviceCredentials {
|
||||
if publicKey := knownDeviceCredential.GetWebauthn(); publicKey != nil {
|
||||
options.AllowCredentials = append(options.AllowCredentials, webauthn.PublicKeyCredentialDescriptor{
|
||||
Type: webauthn.PublicKeyCredentialTypePublicKey,
|
||||
ID: publicKey.GetId(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func fillAllPublicKeyCredentialParameters(
|
||||
options *webauthn.PublicKeyCredentialCreationOptions,
|
||||
allDeviceParams []*device.WebAuthnOptions_PublicKeyCredentialParameters,
|
||||
) {
|
||||
options.PubKeyCredParams = nil
|
||||
for _, deviceParams := range allDeviceParams {
|
||||
p := webauthn.PublicKeyCredentialParameters{}
|
||||
fillPublicKeyCredentialParameters(&p, deviceParams)
|
||||
options.PubKeyCredParams = append(options.PubKeyCredParams, p)
|
||||
}
|
||||
}
|
||||
|
||||
func fillAttestationConveyance(
|
||||
options *webauthn.PublicKeyCredentialCreationOptions,
|
||||
attestationConveyance *device.WebAuthnOptions_AttestationConveyancePreference,
|
||||
) {
|
||||
options.Attestation = ""
|
||||
if attestationConveyance == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch *attestationConveyance {
|
||||
case device.WebAuthnOptions_NONE:
|
||||
options.Attestation = webauthn.AttestationConveyanceNone
|
||||
case device.WebAuthnOptions_INDIRECT:
|
||||
options.Attestation = webauthn.AttestationConveyanceIndirect
|
||||
case device.WebAuthnOptions_DIRECT:
|
||||
options.Attestation = webauthn.AttestationConveyanceDirect
|
||||
case device.WebAuthnOptions_ENTERPRISE:
|
||||
options.Attestation = webauthn.AttestationConveyanceEnterprise
|
||||
}
|
||||
}
|
||||
|
||||
func fillAuthenticatorAttachment(
|
||||
criteria *webauthn.AuthenticatorSelectionCriteria,
|
||||
authenticatorAttachment *device.WebAuthnOptions_AuthenticatorAttachment,
|
||||
) {
|
||||
criteria.AuthenticatorAttachment = ""
|
||||
if authenticatorAttachment == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch *authenticatorAttachment {
|
||||
case device.WebAuthnOptions_CROSS_PLATFORM:
|
||||
criteria.AuthenticatorAttachment = webauthn.AuthenticatorAttachmentCrossPlatform
|
||||
case device.WebAuthnOptions_PLATFORM:
|
||||
criteria.AuthenticatorAttachment = webauthn.AuthenticatorAttachmentPlatform
|
||||
}
|
||||
}
|
||||
|
||||
func fillAuthenticatorSelection(
|
||||
options *webauthn.PublicKeyCredentialCreationOptions,
|
||||
deviceCriteria *device.WebAuthnOptions_AuthenticatorSelectionCriteria,
|
||||
) {
|
||||
options.AuthenticatorSelection = new(webauthn.AuthenticatorSelectionCriteria)
|
||||
fillAuthenticatorAttachment(options.AuthenticatorSelection, deviceCriteria.AuthenticatorAttachment)
|
||||
fillResidentKeyRequirement(options.AuthenticatorSelection, deviceCriteria.ResidentKeyRequirement)
|
||||
options.AuthenticatorSelection.RequireResidentKey = deviceCriteria.GetRequireResidentKey()
|
||||
fillUserVerificationRequirement(options.AuthenticatorSelection, deviceCriteria.UserVerification)
|
||||
}
|
||||
|
||||
func fillPublicKeyCredentialParameters(
|
||||
params *webauthn.PublicKeyCredentialParameters,
|
||||
deviceParams *device.WebAuthnOptions_PublicKeyCredentialParameters,
|
||||
) {
|
||||
params.Type = ""
|
||||
params.COSEAlgorithmIdentifier = 0
|
||||
if deviceParams == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch deviceParams.Type {
|
||||
case device.WebAuthnOptions_PUBLIC_KEY:
|
||||
params.Type = webauthn.PublicKeyCredentialTypePublicKey
|
||||
}
|
||||
params.COSEAlgorithmIdentifier = cose.Algorithm(deviceParams.GetAlg())
|
||||
}
|
||||
|
||||
func fillRequestUserVerificationRequirement(
|
||||
options *webauthn.PublicKeyCredentialRequestOptions,
|
||||
userVerificationRequirement *device.WebAuthnOptions_UserVerificationRequirement,
|
||||
) {
|
||||
options.UserVerification = ""
|
||||
if userVerificationRequirement == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch *userVerificationRequirement {
|
||||
case device.WebAuthnOptions_USER_VERIFICATION_DISCOURAGED:
|
||||
options.UserVerification = webauthn.UserVerificationDiscouraged
|
||||
case device.WebAuthnOptions_USER_VERIFICATION_PREFERRED:
|
||||
options.UserVerification = webauthn.UserVerificationPreferred
|
||||
case device.WebAuthnOptions_USER_VERIFICATION_REQUIRED:
|
||||
options.UserVerification = webauthn.UserVerificationRequired
|
||||
}
|
||||
}
|
||||
|
||||
func fillResidentKeyRequirement(
|
||||
criteria *webauthn.AuthenticatorSelectionCriteria,
|
||||
residentKeyRequirement *device.WebAuthnOptions_ResidentKeyRequirement,
|
||||
) {
|
||||
criteria.ResidentKey = ""
|
||||
if residentKeyRequirement == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch *residentKeyRequirement {
|
||||
case device.WebAuthnOptions_RESIDENT_KEY_DISCOURAGED:
|
||||
criteria.ResidentKey = webauthn.ResidentKeyDiscouraged
|
||||
case device.WebAuthnOptions_RESIDENT_KEY_PREFERRED:
|
||||
criteria.ResidentKey = webauthn.ResidentKeyPreferred
|
||||
case device.WebAuthnOptions_RESIDENT_KEY_REQUIRED:
|
||||
criteria.ResidentKey = webauthn.ResidentKeyRequired
|
||||
}
|
||||
}
|
||||
|
||||
func fillUserVerificationRequirement(
|
||||
criteria *webauthn.AuthenticatorSelectionCriteria,
|
||||
userVerificationRequirement *device.WebAuthnOptions_UserVerificationRequirement,
|
||||
) {
|
||||
criteria.UserVerification = ""
|
||||
if userVerificationRequirement == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch *userVerificationRequirement {
|
||||
case device.WebAuthnOptions_USER_VERIFICATION_DISCOURAGED:
|
||||
criteria.UserVerification = webauthn.UserVerificationDiscouraged
|
||||
case device.WebAuthnOptions_USER_VERIFICATION_PREFERRED:
|
||||
criteria.UserVerification = webauthn.UserVerificationPreferred
|
||||
case device.WebAuthnOptions_USER_VERIFICATION_REQUIRED:
|
||||
criteria.UserVerification = webauthn.UserVerificationRequired
|
||||
}
|
||||
}
|
175
pkg/webauthnutil/options_test.go
Normal file
175
pkg/webauthnutil/options_test.go
Normal file
|
@ -0,0 +1,175 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pomerium/webauthn"
|
||||
"github.com/pomerium/webauthn/cose"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||
)
|
||||
|
||||
func TestGenerateCreationOptions(t *testing.T) {
|
||||
t.Run("random challenge", func(t *testing.T) {
|
||||
key := []byte{1, 2, 3}
|
||||
options1 := GenerateCreationOptions(key, predefinedDeviceTypes["default"], &user.User{
|
||||
Id: "example",
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
})
|
||||
options2 := GenerateCreationOptions(key, predefinedDeviceTypes["default"], &user.User{
|
||||
Id: "example",
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
})
|
||||
assert.NotEqual(t, options1.Challenge, options2.Challenge)
|
||||
})
|
||||
t.Run("default", func(t *testing.T) {
|
||||
key := []byte{1, 2, 3}
|
||||
options := GenerateCreationOptions(key, predefinedDeviceTypes["default"], &user.User{
|
||||
Id: "example",
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
})
|
||||
options.Challenge = nil
|
||||
assert.Equal(t, &webauthn.PublicKeyCredentialCreationOptions{
|
||||
RP: webauthn.PublicKeyCredentialRPEntity{
|
||||
Name: "Pomerium",
|
||||
},
|
||||
User: webauthn.PublicKeyCredentialUserEntity{
|
||||
ID: []byte{
|
||||
0x14, 0x7b, 0x2e, 0x3b, 0xae, 0x95, 0x5b, 0x99,
|
||||
0xbb, 0x4e, 0x89, 0xdd, 0x03, 0xac, 0xae, 0x1d,
|
||||
},
|
||||
DisplayName: "Test User",
|
||||
Name: "test@example.com",
|
||||
},
|
||||
Challenge: nil,
|
||||
PubKeyCredParams: []webauthn.PublicKeyCredentialParameters{
|
||||
{Type: "public-key", COSEAlgorithmIdentifier: -7},
|
||||
{Type: "public-key", COSEAlgorithmIdentifier: -257},
|
||||
{Type: "public-key", COSEAlgorithmIdentifier: -65535},
|
||||
},
|
||||
Timeout: 900000000000,
|
||||
ExcludeCredentials: nil,
|
||||
AuthenticatorSelection: &webauthn.AuthenticatorSelectionCriteria{
|
||||
UserVerification: "preferred",
|
||||
},
|
||||
Attestation: "direct",
|
||||
}, options)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateRequestOptions(t *testing.T) {
|
||||
t.Run("random challenge", func(t *testing.T) {
|
||||
key := []byte{1, 2, 3}
|
||||
options1 := GenerateRequestOptions(key, predefinedDeviceTypes["default"], nil)
|
||||
options2 := GenerateRequestOptions(key, predefinedDeviceTypes["default"], nil)
|
||||
assert.NotEqual(t, options1.Challenge, options2.Challenge)
|
||||
})
|
||||
t.Run("default", func(t *testing.T) {
|
||||
key := []byte{1, 2, 3}
|
||||
options := GenerateRequestOptions(key, predefinedDeviceTypes["default"], []*device.Credential{
|
||||
{Id: "device1", Specifier: &device.Credential_Webauthn{Webauthn: &device.Credential_WebAuthn{
|
||||
Id: []byte{4, 5, 6},
|
||||
}}},
|
||||
})
|
||||
options.Challenge = nil
|
||||
assert.Equal(t, &webauthn.PublicKeyCredentialRequestOptions{
|
||||
Timeout: 900000000000,
|
||||
AllowCredentials: []webauthn.PublicKeyCredentialDescriptor{
|
||||
{Type: "public-key", ID: []byte{4, 5, 6}},
|
||||
},
|
||||
UserVerification: "preferred",
|
||||
}, options)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFillAttestationConveyance(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
expect webauthn.AttestationConveyancePreference
|
||||
in *device.WebAuthnOptions_AttestationConveyancePreference
|
||||
}{
|
||||
{"", nil},
|
||||
{"none", device.WebAuthnOptions_NONE.Enum()},
|
||||
{"indirect", device.WebAuthnOptions_INDIRECT.Enum()},
|
||||
{"direct", device.WebAuthnOptions_DIRECT.Enum()},
|
||||
{"enterprise", device.WebAuthnOptions_ENTERPRISE.Enum()},
|
||||
} {
|
||||
options := new(webauthn.PublicKeyCredentialCreationOptions)
|
||||
fillAttestationConveyance(options, testCase.in)
|
||||
actual := options.Attestation
|
||||
assert.Equal(t, testCase.expect, actual, "expected %v for %v", testCase.expect, testCase.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillAuthenticatorSelection(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
expect webauthn.AuthenticatorAttachment
|
||||
in *device.WebAuthnOptions_AuthenticatorAttachment
|
||||
}{
|
||||
{"", nil},
|
||||
{"cross-platform", device.WebAuthnOptions_CROSS_PLATFORM.Enum()},
|
||||
{"platform", device.WebAuthnOptions_PLATFORM.Enum()},
|
||||
} {
|
||||
criteria := new(webauthn.AuthenticatorSelectionCriteria)
|
||||
fillAuthenticatorAttachment(criteria, testCase.in)
|
||||
actual := criteria.AuthenticatorAttachment
|
||||
assert.Equal(t, testCase.expect, actual, "expected %v for %v", testCase.expect, testCase.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPublicKeyCredentialParameters(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
expectedType webauthn.PublicKeyCredentialType
|
||||
expectedAlgorithm cose.Algorithm
|
||||
in *device.WebAuthnOptions_PublicKeyCredentialParameters
|
||||
}{
|
||||
{"", 0, nil},
|
||||
{"public-key", -7, &device.WebAuthnOptions_PublicKeyCredentialParameters{
|
||||
Type: device.WebAuthnOptions_PUBLIC_KEY, Alg: -7}},
|
||||
} {
|
||||
params := new(webauthn.PublicKeyCredentialParameters)
|
||||
fillPublicKeyCredentialParameters(params, testCase.in)
|
||||
actualType := params.Type
|
||||
assert.Equal(t, testCase.expectedType, actualType, "expected %v for %v", testCase.expectedType, testCase.in)
|
||||
actualAlgorithm := params.COSEAlgorithmIdentifier
|
||||
assert.Equal(t, testCase.expectedAlgorithm, actualAlgorithm, "expected %v for %v", testCase.expectedType, testCase.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillResidentKeyRequirement(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
expect webauthn.ResidentKeyType
|
||||
in *device.WebAuthnOptions_ResidentKeyRequirement
|
||||
}{
|
||||
{"", nil},
|
||||
{"discouraged", device.WebAuthnOptions_RESIDENT_KEY_DISCOURAGED.Enum()},
|
||||
{"preferred", device.WebAuthnOptions_RESIDENT_KEY_PREFERRED.Enum()},
|
||||
{"required", device.WebAuthnOptions_RESIDENT_KEY_REQUIRED.Enum()},
|
||||
} {
|
||||
criteria := new(webauthn.AuthenticatorSelectionCriteria)
|
||||
fillResidentKeyRequirement(criteria, testCase.in)
|
||||
actual := criteria.ResidentKey
|
||||
assert.Equal(t, testCase.expect, actual, "expected %v for %v", testCase.expect, testCase.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillUserVerificationRequirement(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
expect webauthn.UserVerificationRequirement
|
||||
in *device.WebAuthnOptions_UserVerificationRequirement
|
||||
}{
|
||||
{"", nil},
|
||||
{"discouraged", device.WebAuthnOptions_USER_VERIFICATION_DISCOURAGED.Enum()},
|
||||
{"preferred", device.WebAuthnOptions_USER_VERIFICATION_PREFERRED.Enum()},
|
||||
{"required", device.WebAuthnOptions_USER_VERIFICATION_REQUIRED.Enum()},
|
||||
} {
|
||||
criteria := new(webauthn.AuthenticatorSelectionCriteria)
|
||||
fillUserVerificationRequirement(criteria, testCase.in)
|
||||
actual := criteria.UserVerification
|
||||
assert.Equal(t, testCase.expect, actual, "expected %v for %v", testCase.expect, testCase.in)
|
||||
}
|
||||
}
|
48
pkg/webauthnutil/user.go
Normal file
48
pkg/webauthnutil/user.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/pomerium/webauthn"
|
||||
|
||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||
)
|
||||
|
||||
var pomeriumUserNamespace = uuid.MustParse("2929d3f7-f0b0-478f-9dd5-970d51eb3859")
|
||||
|
||||
// GetUserEntity gets the PublicKeyCredentialUserEntity from a Pomerium user.
|
||||
func GetUserEntity(pomeriumUser *user.User) webauthn.PublicKeyCredentialUserEntity {
|
||||
name := pomeriumUser.GetEmail()
|
||||
if name == "" {
|
||||
name = pomeriumUser.GetId()
|
||||
}
|
||||
displayName := pomeriumUser.GetName()
|
||||
if displayName == "" {
|
||||
displayName = name
|
||||
}
|
||||
return webauthn.PublicKeyCredentialUserEntity{
|
||||
ID: GetUserEntityID(pomeriumUser.GetId()),
|
||||
DisplayName: displayName,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserEntityID gets the UserEntity ID.
|
||||
//
|
||||
// The WebAuthn spec states:
|
||||
//
|
||||
// > The user handle of the user account entity. A user handle is an opaque byte sequence with a maximum size of 64
|
||||
// > bytes, and is not meant to be displayed to the user.
|
||||
// >
|
||||
// > To ensure secure operation, authentication and authorization decisions MUST be made on the basis of this id
|
||||
// > member, not the displayName nor name members. See Section 6.1 of [RFC8266].
|
||||
// >
|
||||
// > The user handle MUST NOT contain personally identifying information about the user, such as a username or e-mail
|
||||
// > address; see §14.6.1 User Handle Contents for details. The user handle MUST NOT be empty, though it MAY be
|
||||
// > null.
|
||||
//
|
||||
// To meet these requirements we hash the user ID (since it's often an email address in the IdP) using a UUID v5 in a
|
||||
// custom UUID namespace: 2929d3f7-f0b0-478f-9dd5-970d51eb3859.
|
||||
func GetUserEntityID(pomeriumUserID string) []byte {
|
||||
id := uuid.NewSHA1(pomeriumUserNamespace, []byte(pomeriumUserID))
|
||||
return id[:]
|
||||
}
|
48
pkg/webauthnutil/user_test.go
Normal file
48
pkg/webauthnutil/user_test.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
package webauthnutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||
)
|
||||
|
||||
func TestGetUserEntity(t *testing.T) {
|
||||
t.Run("name as email", func(t *testing.T) {
|
||||
ue := GetUserEntity(&user.User{
|
||||
Id: "test",
|
||||
Email: "test@example.com",
|
||||
})
|
||||
assert.Equal(t, "test@example.com", ue.Name)
|
||||
})
|
||||
t.Run("name as id", func(t *testing.T) {
|
||||
ue := GetUserEntity(&user.User{
|
||||
Id: "test",
|
||||
})
|
||||
assert.Equal(t, "test", ue.Name)
|
||||
})
|
||||
t.Run("displayName as name", func(t *testing.T) {
|
||||
ue := GetUserEntity(&user.User{
|
||||
Id: "test",
|
||||
Name: "Test User",
|
||||
})
|
||||
assert.Equal(t, "Test User", ue.DisplayName)
|
||||
})
|
||||
t.Run("displayName as email", func(t *testing.T) {
|
||||
ue := GetUserEntity(&user.User{
|
||||
Id: "test",
|
||||
Email: "test@example.com",
|
||||
})
|
||||
assert.Equal(t, "test@example.com", ue.DisplayName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUserEntityID(t *testing.T) {
|
||||
userID := "test@example.com"
|
||||
rawUserEntityID := GetUserEntityID(userID)
|
||||
userEntityUUID, err := uuid.FromBytes(rawUserEntityID)
|
||||
assert.NoError(t, err, "should return a UUID")
|
||||
assert.Equal(t, "8c0ac353-406f-5c08-845d-b72779779a42", userEntityUUID.String())
|
||||
}
|
2
pkg/webauthnutil/webauthnutil.go
Normal file
2
pkg/webauthnutil/webauthnutil.go
Normal file
|
@ -0,0 +1,2 @@
|
|||
// Package webauthnutil contains types and functions for working with the webauthn package.
|
||||
package webauthnutil
|
Loading…
Add table
Reference in a new issue