Merge pull request 'feat(web): sign in with owner passkeys' (#14) from feat/passkeys into main
89 files · +7974 -207
M README.md +9 -1
| @@ -74,6 +74,7 @@ ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [- | |||
| 74 | 74 | ermite runner list | remove <id> | |
| 75 | 75 | ermite web login # prints a one-time sign-in link (60 s) | |
| 76 | 76 | ermite web logout --all # signs every browser out | |
| 77 | + | ermite web passkey list | remove <id> # the owner's passkeys; removing one signs every browser out | |
| 77 | 78 | ermite signing-key show # public key and allowed_signers line of squash commits | |
| 78 | 79 | ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file | |
| 79 | 80 | ermite version # client and server, warns about missing server features | |
| @@ -118,7 +119,14 @@ Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts | |||
| 118 | 119 | ||
| 119 | 120 | ## Web sign-in | |
| 120 | 121 | ||
| 121 | - | `ermite web login`, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it and confirming sets a session cookie for 12 hours (never longer than the token that asked for the link). It needs `https` in `ERMITE_PUBLIC_URL` (except on localhost). Browser writes require the session and an `Origin` equal to `ERMITE_PUBLIC_URL`. `ermite web logout --all`, or revoking a token with every scope, ends every session. | |
| 122 | + | Two ways in, both for the owner, both giving the same session cookie for 12 hours. Neither works over plain `http` except on localhost: the cookie is `Secure`. | |
| 123 | + | ||
| 124 | + | - **Passkey** (a phone, a laptop, a security key): open `/-/login` and press "Sign in with a passkey". Nothing to type, no terminal. A passkey is added once from `/-/passkeys` while signed in. | |
| 125 | + | - **Link**: `ermite web login`, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it and confirming signs the browser in (never longer than the token that asked for the link). It is also how to sign in the first time, before any passkey exists. | |
| 126 | + | ||
| 127 | + | Passkeys need `ERMITE_PUBLIC_URL` to be `https` (or `http://localhost`) with a **host name, not an IP address**: WebAuthn ties a passkey to that name, and a passkey registered for one name does not work on another. On `http://127.0.0.1:…` the server starts without passkeys (the log says `passkeys disabled`, the routes and links are absent, `ermite version` does not list `passkeys`). Up to 20 passkeys can be registered; each asks for user verification (a fingerprint, a face or a PIN), and the server stores only the public key. A security key that reports a signature counter is checked for cloning; synced passkeys (iCloud, Google, a password manager) report none, so they are not. | |
| 128 | + | ||
| 129 | + | `ermite web passkey list` shows them, `ermite web passkey remove <id>` removes one. Removing a passkey, `ermite web logout --all`, or revoking a token with every scope ends every session, on every device. Browser writes require the session and an `Origin` equal to `ERMITE_PUBLIC_URL`. If you lose every device, `ermite web login` still works from a terminal. | |
| 122 | 130 | ||
| 123 | 131 | The server keeps its runtime files in `$ERMITE_DATA_DIR/run/` (the hook socket and scripts that git calls on each push); the hook socket path must stay under about 100 bytes. | |
| 124 | 132 | ||
M gen/ermite/storage/session.pb.go +209 -11
| @@ -13,6 +13,7 @@ package storagepb | |||
| 13 | 13 | import ( | |
| 14 | 14 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" | |
| 15 | 15 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" | |
| 16 | + | timestamppb "google.golang.org/protobuf/types/known/timestamppb" | |
| 16 | 17 | reflect "reflect" | |
| 17 | 18 | sync "sync" | |
| 18 | 19 | unsafe "unsafe" | |
| @@ -120,16 +121,208 @@ func (x *SessionEpoch) GetEpoch() uint64 { | |||
| 120 | 121 | return 0 | |
| 121 | 122 | } | |
| 122 | 123 | ||
| 124 | + | // PasskeyUser is stored under the key "passkey/user": the WebAuthn user | |
| 125 | + | // handle of the owner, generated on first use. It identifies the owner to | |
| 126 | + | // authenticators and never changes. | |
| 127 | + | type PasskeyUser struct { | |
| 128 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 129 | + | // 32 random bytes. | |
| 130 | + | Handle []byte `protobuf:"bytes,1,opt,name=handle,proto3" json:"handle,omitempty"` | |
| 131 | + | unknownFields protoimpl.UnknownFields | |
| 132 | + | sizeCache protoimpl.SizeCache | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (x *PasskeyUser) Reset() { | |
| 136 | + | *x = PasskeyUser{} | |
| 137 | + | mi := &file_ermite_storage_session_proto_msgTypes[2] | |
| 138 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 139 | + | ms.StoreMessageInfo(mi) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (x *PasskeyUser) String() string { | |
| 143 | + | return protoimpl.X.MessageStringOf(x) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (*PasskeyUser) ProtoMessage() {} | |
| 147 | + | ||
| 148 | + | func (x *PasskeyUser) ProtoReflect() protoreflect.Message { | |
| 149 | + | mi := &file_ermite_storage_session_proto_msgTypes[2] | |
| 150 | + | if x != nil { | |
| 151 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 152 | + | if ms.LoadMessageInfo() == nil { | |
| 153 | + | ms.StoreMessageInfo(mi) | |
| 154 | + | } | |
| 155 | + | return ms | |
| 156 | + | } | |
| 157 | + | return mi.MessageOf(x) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // Deprecated: Use PasskeyUser.ProtoReflect.Descriptor instead. | |
| 161 | + | func (*PasskeyUser) Descriptor() ([]byte, []int) { | |
| 162 | + | return file_ermite_storage_session_proto_rawDescGZIP(), []int{2} | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (x *PasskeyUser) GetHandle() []byte { | |
| 166 | + | if x != nil { | |
| 167 | + | return x.Handle | |
| 168 | + | } | |
| 169 | + | return nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Passkey is stored under the key "passkey/cred/<base64url credential id>": | |
| 173 | + | // a WebAuthn credential that can sign the owner in (ADR 0026). It holds the | |
| 174 | + | // public key only. | |
| 175 | + | type Passkey struct { | |
| 176 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 177 | + | // Credential id, at most 1023 bytes. | |
| 178 | + | Id []byte `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` | |
| 179 | + | // Name given by the owner. | |
| 180 | + | Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` | |
| 181 | + | // COSE public key. | |
| 182 | + | PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` | |
| 183 | + | AttestationType string `protobuf:"bytes,4,opt,name=attestation_type,json=attestationType,proto3" json:"attestation_type,omitempty"` | |
| 184 | + | Transports []string `protobuf:"bytes,5,rep,name=transports,proto3" json:"transports,omitempty"` | |
| 185 | + | // Last signature counter; 0 for synced passkeys. | |
| 186 | + | SignCount uint32 `protobuf:"varint,6,opt,name=sign_count,json=signCount,proto3" json:"sign_count,omitempty"` | |
| 187 | + | BackupEligible bool `protobuf:"varint,7,opt,name=backup_eligible,json=backupEligible,proto3" json:"backup_eligible,omitempty"` | |
| 188 | + | BackupState bool `protobuf:"varint,8,opt,name=backup_state,json=backupState,proto3" json:"backup_state,omitempty"` | |
| 189 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 190 | + | // Unset until the passkey is first used. | |
| 191 | + | LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"` | |
| 192 | + | unknownFields protoimpl.UnknownFields | |
| 193 | + | sizeCache protoimpl.SizeCache | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (x *Passkey) Reset() { | |
| 197 | + | *x = Passkey{} | |
| 198 | + | mi := &file_ermite_storage_session_proto_msgTypes[3] | |
| 199 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 200 | + | ms.StoreMessageInfo(mi) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (x *Passkey) String() string { | |
| 204 | + | return protoimpl.X.MessageStringOf(x) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func (*Passkey) ProtoMessage() {} | |
| 208 | + | ||
| 209 | + | func (x *Passkey) ProtoReflect() protoreflect.Message { | |
| 210 | + | mi := &file_ermite_storage_session_proto_msgTypes[3] | |
| 211 | + | if x != nil { | |
| 212 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 213 | + | if ms.LoadMessageInfo() == nil { | |
| 214 | + | ms.StoreMessageInfo(mi) | |
| 215 | + | } | |
| 216 | + | return ms | |
| 217 | + | } | |
| 218 | + | return mi.MessageOf(x) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Deprecated: Use Passkey.ProtoReflect.Descriptor instead. | |
| 222 | + | func (*Passkey) Descriptor() ([]byte, []int) { | |
| 223 | + | return file_ermite_storage_session_proto_rawDescGZIP(), []int{3} | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (x *Passkey) GetId() []byte { | |
| 227 | + | if x != nil { | |
| 228 | + | return x.Id | |
| 229 | + | } | |
| 230 | + | return nil | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func (x *Passkey) GetName() string { | |
| 234 | + | if x != nil { | |
| 235 | + | return x.Name | |
| 236 | + | } | |
| 237 | + | return "" | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func (x *Passkey) GetPublicKey() []byte { | |
| 241 | + | if x != nil { | |
| 242 | + | return x.PublicKey | |
| 243 | + | } | |
| 244 | + | return nil | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func (x *Passkey) GetAttestationType() string { | |
| 248 | + | if x != nil { | |
| 249 | + | return x.AttestationType | |
| 250 | + | } | |
| 251 | + | return "" | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (x *Passkey) GetTransports() []string { | |
| 255 | + | if x != nil { | |
| 256 | + | return x.Transports | |
| 257 | + | } | |
| 258 | + | return nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func (x *Passkey) GetSignCount() uint32 { | |
| 262 | + | if x != nil { | |
| 263 | + | return x.SignCount | |
| 264 | + | } | |
| 265 | + | return 0 | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func (x *Passkey) GetBackupEligible() bool { | |
| 269 | + | if x != nil { | |
| 270 | + | return x.BackupEligible | |
| 271 | + | } | |
| 272 | + | return false | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func (x *Passkey) GetBackupState() bool { | |
| 276 | + | if x != nil { | |
| 277 | + | return x.BackupState | |
| 278 | + | } | |
| 279 | + | return false | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func (x *Passkey) GetCreatedAt() *timestamppb.Timestamp { | |
| 283 | + | if x != nil { | |
| 284 | + | return x.CreatedAt | |
| 285 | + | } | |
| 286 | + | return nil | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (x *Passkey) GetLastUsedAt() *timestamppb.Timestamp { | |
| 290 | + | if x != nil { | |
| 291 | + | return x.LastUsedAt | |
| 292 | + | } | |
| 293 | + | return nil | |
| 294 | + | } | |
| 295 | + | ||
| 123 | 296 | var File_ermite_storage_session_proto protoreflect.FileDescriptor | |
| 124 | 297 | ||
| 125 | 298 | const file_ermite_storage_session_proto_rawDesc = "" + | |
| 126 | 299 | "\n" + | |
| 127 | - | "\x1cermite/storage/session.proto\x12\x0eermite.storage\"\x1e\n" + | |
| 300 | + | "\x1cermite/storage/session.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x1e\n" + | |
| 128 | 301 | "\n" + | |
| 129 | 302 | "SessionKey\x12\x10\n" + | |
| 130 | 303 | "\x03key\x18\x01 \x01(\fR\x03key\"$\n" + | |
| 131 | 304 | "\fSessionEpoch\x12\x14\n" + | |
| 132 | - | "\x05epoch\x18\x01 \x01(\x04R\x05epochB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |
| 305 | + | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\"%\n" + | |
| 306 | + | "\vPasskeyUser\x12\x16\n" + | |
| 307 | + | "\x06handle\x18\x01 \x01(\fR\x06handle\"\xfb\x02\n" + | |
| 308 | + | "\aPasskey\x12\x0e\n" + | |
| 309 | + | "\x02id\x18\x01 \x01(\fR\x02id\x12\x12\n" + | |
| 310 | + | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1d\n" + | |
| 311 | + | "\n" + | |
| 312 | + | "public_key\x18\x03 \x01(\fR\tpublicKey\x12)\n" + | |
| 313 | + | "\x10attestation_type\x18\x04 \x01(\tR\x0fattestationType\x12\x1e\n" + | |
| 314 | + | "\n" + | |
| 315 | + | "transports\x18\x05 \x03(\tR\n" + | |
| 316 | + | "transports\x12\x1d\n" + | |
| 317 | + | "\n" + | |
| 318 | + | "sign_count\x18\x06 \x01(\rR\tsignCount\x12'\n" + | |
| 319 | + | "\x0fbackup_eligible\x18\a \x01(\bR\x0ebackupEligible\x12!\n" + | |
| 320 | + | "\fbackup_state\x18\b \x01(\bR\vbackupState\x129\n" + | |
| 321 | + | "\n" + | |
| 322 | + | "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" + | |
| 323 | + | "\flast_used_at\x18\n" + | |
| 324 | + | " \x01(\v2\x1a.google.protobuf.TimestampR\n" + | |
| 325 | + | "lastUsedAtB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |
| 133 | 326 | ||
| 134 | 327 | var ( | |
| 135 | 328 | file_ermite_storage_session_proto_rawDescOnce sync.Once | |
| @@ -143,17 +336,22 @@ func file_ermite_storage_session_proto_rawDescGZIP() []byte { | |||
| 143 | 336 | return file_ermite_storage_session_proto_rawDescData | |
| 144 | 337 | } | |
| 145 | 338 | ||
| 146 | - | var file_ermite_storage_session_proto_msgTypes = make([]protoimpl.MessageInfo, 2) | |
| 339 | + | var file_ermite_storage_session_proto_msgTypes = make([]protoimpl.MessageInfo, 4) | |
| 147 | 340 | var file_ermite_storage_session_proto_goTypes = []any{ | |
| 148 | - | (*SessionKey)(nil), // 0: ermite.storage.SessionKey | |
| 149 | - | (*SessionEpoch)(nil), // 1: ermite.storage.SessionEpoch | |
| 341 | + | (*SessionKey)(nil), // 0: ermite.storage.SessionKey | |
| 342 | + | (*SessionEpoch)(nil), // 1: ermite.storage.SessionEpoch | |
| 343 | + | (*PasskeyUser)(nil), // 2: ermite.storage.PasskeyUser | |
| 344 | + | (*Passkey)(nil), // 3: ermite.storage.Passkey | |
| 345 | + | (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp | |
| 150 | 346 | } | |
| 151 | 347 | var file_ermite_storage_session_proto_depIdxs = []int32{ | |
| 152 | - | 0, // [0:0] is the sub-list for method output_type | |
| 153 | - | 0, // [0:0] is the sub-list for method input_type | |
| 154 | - | 0, // [0:0] is the sub-list for extension type_name | |
| 155 | - | 0, // [0:0] is the sub-list for extension extendee | |
| 156 | - | 0, // [0:0] is the sub-list for field type_name | |
| 348 | + | 4, // 0: ermite.storage.Passkey.created_at:type_name -> google.protobuf.Timestamp | |
| 349 | + | 4, // 1: ermite.storage.Passkey.last_used_at:type_name -> google.protobuf.Timestamp | |
| 350 | + | 2, // [2:2] is the sub-list for method output_type | |
| 351 | + | 2, // [2:2] is the sub-list for method input_type | |
| 352 | + | 2, // [2:2] is the sub-list for extension type_name | |
| 353 | + | 2, // [2:2] is the sub-list for extension extendee | |
| 354 | + | 0, // [0:2] is the sub-list for field type_name | |
| 157 | 355 | } | |
| 158 | 356 | ||
| 159 | 357 | func init() { file_ermite_storage_session_proto_init() } | |
| @@ -167,7 +365,7 @@ func file_ermite_storage_session_proto_init() { | |||
| 167 | 365 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 168 | 366 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_session_proto_rawDesc), len(file_ermite_storage_session_proto_rawDesc)), | |
| 169 | 367 | NumEnums: 0, | |
| 170 | - | NumMessages: 2, | |
| 368 | + | NumMessages: 4, | |
| 171 | 369 | NumExtensions: 0, | |
| 172 | 370 | NumServices: 0, | |
| 173 | 371 | }, | |
M go.mod +8 -0
| @@ -21,6 +21,8 @@ require ( | |||
| 21 | 21 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 22 | 22 | github.com/bnema/kvconf v0.1.2 | |
| 23 | 23 | github.com/bnema/zerowrap v1.4.1 | |
| 24 | + | github.com/descope/virtualwebauthn v1.0.5 | |
| 25 | + | github.com/go-webauthn/webauthn v0.18.2 | |
| 24 | 26 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 25 | 27 | github.com/spf13/cobra v1.10.2 | |
| 26 | 28 | github.com/stretchr/testify v1.12.1 | |
| @@ -81,14 +83,17 @@ require ( | |||
| 81 | 83 | github.com/fatih/structs v1.1.0 // indirect | |
| 82 | 84 | github.com/felixge/httpsnoop v1.1.0 // indirect | |
| 83 | 85 | github.com/fsnotify/fsnotify v1.9.0 // indirect | |
| 86 | + | github.com/fxamacker/cbor/v2 v2.9.4 // indirect | |
| 84 | 87 | github.com/go-logr/logr v1.4.4 // indirect | |
| 85 | 88 | github.com/go-logr/stdr v1.2.2 // indirect | |
| 86 | 89 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | |
| 90 | + | github.com/go-webauthn/x v0.3.1 // indirect | |
| 87 | 91 | github.com/gobwas/glob v0.2.3 // indirect | |
| 88 | 92 | github.com/gofrs/flock v0.13.1 // indirect | |
| 89 | 93 | github.com/gohugoio/hashstructure v0.6.0 // indirect | |
| 90 | 94 | github.com/gohugoio/hugo v0.164.0 // indirect | |
| 91 | 95 | github.com/google/go-containerregistry v0.22.1 // indirect | |
| 96 | + | github.com/google/go-tpm v0.9.8 // indirect | |
| 92 | 97 | github.com/google/uuid v1.6.0 // indirect | |
| 93 | 98 | github.com/huandu/xstrings v1.5.0 // indirect | |
| 94 | 99 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| @@ -118,6 +123,7 @@ require ( | |||
| 118 | 123 | github.com/pelletier/go-toml v1.9.5 // indirect | |
| 119 | 124 | github.com/pelletier/go-toml/v2 v2.4.3 // indirect | |
| 120 | 125 | github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect | |
| 126 | + | github.com/philhofer/fwd v1.2.0 // indirect | |
| 121 | 127 | github.com/quic-go/qpack v0.6.0 // indirect | |
| 122 | 128 | github.com/quic-go/quic-go v0.62.0 // indirect | |
| 123 | 129 | github.com/rivo/uniseg v0.4.7 // indirect | |
| @@ -134,7 +140,9 @@ require ( | |||
| 134 | 140 | github.com/tdewolff/parse/v2 v2.8.12 // indirect | |
| 135 | 141 | github.com/tetratelabs/wazero v1.12.0 // indirect | |
| 136 | 142 | github.com/tidwall/btree v1.8.1 // indirect | |
| 143 | + | github.com/tinylib/msgp v1.6.4 // indirect | |
| 137 | 144 | github.com/vektra/mockery/v3 v3.8.0 // indirect | |
| 145 | + | github.com/x448/float16 v0.8.4 // indirect | |
| 138 | 146 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | |
| 139 | 147 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect | |
| 140 | 148 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect | |
M go.sum +20 -2 generated file: collapsed
M internal/adapters/client/client_test.go +6 -1
| @@ -140,10 +140,15 @@ func TestMissingFeatures(t *testing.T) { | |||
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | 142 | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners}, | |
| 143 | - | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | |
| 143 | + | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 146 | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, | |
| 147 | + | client.FeaturePasskeys, | |
| 148 | + | })) | |
| 149 | + | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |
| 150 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | |
| 151 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, | |
| 147 | 152 | })) | |
| 148 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 149 | 154 | } | |
M internal/adapters/client/features.go +5 -1
| @@ -22,6 +22,10 @@ const ( | |||
| 22 | 22 | FeatureSigningKey = "signing_key" | |
| 23 | 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | |
| 24 | 24 | FeatureSession = "session" | |
| 25 | + | // FeaturePasskeys is SessionService.ListPasskeys and RemovePasskey | |
| 26 | + | // (`ermite web passkey`). A server lists it only when passkeys are | |
| 27 | + | // available (https public URL with a DNS host name). | |
| 28 | + | FeaturePasskeys = "passkeys" | |
| 25 | 29 | // FeatureTokenBranchPrefixes is Token.branch_prefixes and | |
| 26 | 30 | // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`). | |
| 27 | 31 | // A server without it ignores the field and would create an unlimited | |
| @@ -37,7 +41,7 @@ const ( | |||
| 37 | 41 | ||
| 38 | 42 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 39 | 43 | func UsedFeatures() []string { | |
| 40 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners} | |
| 44 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeaturePasskeys} | |
| 41 | 45 | } | |
| 42 | 46 | ||
| 43 | 47 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
M internal/adapters/kvstore/mergerequests_test.go +11 -2
| @@ -360,7 +360,8 @@ func assertNoMergeRequestKeys(t *testing.T, mrs *kvstore.MergeRequests, n domain | |||
| 360 | 360 | // TestDeleteRacingWithWrites: writes racing with Delete either land before it | |
| 361 | 361 | // and are deleted, or are refused (not found after it, or a conflict when the | |
| 362 | 362 | // retries run out under this contention); nothing is left for a repository | |
| 363 | - | // created again under the name. | |
| 363 | + | // created again under the name. Delete itself may report a conflict under the | |
| 364 | + | // same contention and is then called again. | |
| 364 | 365 | func TestDeleteRacingWithWrites(t *testing.T) { | |
| 365 | 366 | ctx := context.Background() | |
| 366 | 367 | for round := range 20 { | |
| @@ -394,7 +395,15 @@ func TestDeleteRacingWithWrites(t *testing.T) { | |||
| 394 | 395 | } | |
| 395 | 396 | }() | |
| 396 | 397 | } | |
| 397 | - | delErr := s.Delete(ctx, n) | |
| 398 | + | // Delete gives up with a conflict when the writers keep rewriting the | |
| 399 | + | // repository key for deleteTries listings in a row; it is documented as | |
| 400 | + | // safe to call again, as a caller does. | |
| 401 | + | var delErr error | |
| 402 | + | for range 20 { | |
| 403 | + | if delErr = s.Delete(ctx, n); !errors.Is(delErr, domain.ErrConflict) { | |
| 404 | + | break | |
| 405 | + | } | |
| 406 | + | } | |
| 398 | 407 | wg.Wait() | |
| 399 | 408 | require.NoError(t, delErr) | |
| 400 | 409 | ||
A internal/adapters/kvstore/passkeys.go +235 -0
| @@ -0,0 +1,235 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/rand" | |
| 9 | + | "encoding/base64" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "slices" | |
| 13 | + | "unicode/utf8" | |
| 14 | + | ||
| 15 | + | kv "bnema.dev/ermite-kv" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | "google.golang.org/protobuf/proto" | |
| 18 | + | ||
| 19 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | const ( | |
| 25 | + | passkeyUserKey = "passkey/user" | |
| 26 | + | passkeyCredPrefix = "passkey/cred/" | |
| 27 | + | ||
| 28 | + | // passkeyHandleLen is the size of the WebAuthn user handle, in bytes. | |
| 29 | + | passkeyHandleLen = 32 | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // Passkeys is the ermite-kv backed ports.PasskeyStore. It shares the database | |
| 33 | + | // of the Store it comes from. | |
| 34 | + | type Passkeys struct { | |
| 35 | + | db *kv.DB | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | var _ ports.PasskeyStore = (*Passkeys)(nil) | |
| 39 | + | ||
| 40 | + | // Passkeys returns the passkey store over the same database. It is valid until | |
| 41 | + | // the Store is closed. | |
| 42 | + | func (s *Store) Passkeys() *Passkeys { return &Passkeys{db: s.db} } | |
| 43 | + | ||
| 44 | + | func passkeyKey(id []byte) []byte { | |
| 45 | + | return []byte(passkeyCredPrefix + base64.RawURLEncoding.EncodeToString(id)) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // validPasskeyID reports whether id can be a credential id. | |
| 49 | + | func validPasskeyID(id []byte) bool { return len(id) > 0 && len(id) <= domain.MaxPasskeyIDLen } | |
| 50 | + | ||
| 51 | + | // UserHandle reads passkey/user and generates it when absent. The handle is | |
| 52 | + | // stable for the life of the store. | |
| 53 | + | func (s *Passkeys) UserHandle(ctx context.Context) ([]byte, error) { | |
| 54 | + | for range updateAttempts { | |
| 55 | + | if item, ok := s.db.Get(ctx, []byte(passkeyUserKey)); ok { | |
| 56 | + | var m storagepb.PasskeyUser | |
| 57 | + | if err := proto.Unmarshal(item.Value, &m); err != nil { | |
| 58 | + | return nil, fmt.Errorf("decode passkey user: %w", err) | |
| 59 | + | } | |
| 60 | + | if len(m.GetHandle()) != passkeyHandleLen { | |
| 61 | + | return nil, fmt.Errorf("decode passkey user: %d bytes, want %d", len(m.GetHandle()), passkeyHandleLen) | |
| 62 | + | } | |
| 63 | + | return bytes.Clone(m.GetHandle()), nil | |
| 64 | + | } | |
| 65 | + | handle := make([]byte, passkeyHandleLen) | |
| 66 | + | if _, err := rand.Read(handle); err != nil { | |
| 67 | + | return nil, fmt.Errorf("generate passkey user handle: %w", err) | |
| 68 | + | } | |
| 69 | + | var b kv.Batch | |
| 70 | + | b.RequireAbsent([]byte(passkeyUserKey)) | |
| 71 | + | b.Put([]byte(passkeyUserKey), mustMarshal(&storagepb.PasskeyUser{Handle: handle})) | |
| 72 | + | _, err := s.db.Apply(ctx, &b) | |
| 73 | + | if errors.Is(err, kv.ErrConflict) { | |
| 74 | + | continue // written meanwhile: read it | |
| 75 | + | } | |
| 76 | + | if err != nil { | |
| 77 | + | return nil, fmt.Errorf("store passkey user handle: %w", err) | |
| 78 | + | } | |
| 79 | + | log := zerowrap.FromCtx(ctx) | |
| 80 | + | log.Info().Msg("passkey user handle generated") | |
| 81 | + | return handle, nil | |
| 82 | + | } | |
| 83 | + | return nil, fmt.Errorf("store passkey user handle: %w", domain.ErrConflict) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // List returns the passkeys ordered by creation time, then id. | |
| 87 | + | func (s *Passkeys) List(ctx context.Context) ([]domain.Passkey, error) { | |
| 88 | + | var out []domain.Passkey | |
| 89 | + | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(passkeyCredPrefix)) { | |
| 90 | + | p, err := decodePasskey(item.Value) | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 93 | + | } | |
| 94 | + | out = append(out, p) | |
| 95 | + | } | |
| 96 | + | slices.SortStableFunc(out, func(a, b domain.Passkey) int { | |
| 97 | + | if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 { | |
| 98 | + | return c | |
| 99 | + | } | |
| 100 | + | return bytes.Compare(a.ID, b.ID) | |
| 101 | + | }) | |
| 102 | + | return out, nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Get reads passkey/cred/<id>. | |
| 106 | + | func (s *Passkeys) Get(ctx context.Context, id []byte) (domain.Passkey, error) { | |
| 107 | + | if !validPasskeyID(id) { | |
| 108 | + | return domain.Passkey{}, domain.ErrNotFound | |
| 109 | + | } | |
| 110 | + | item, ok := s.db.Get(ctx, passkeyKey(id)) | |
| 111 | + | if !ok { | |
| 112 | + | return domain.Passkey{}, domain.ErrNotFound | |
| 113 | + | } | |
| 114 | + | return decodePasskey(item.Value) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Create stores passkey/cred/<id> with an "absent" precondition: a taken id | |
| 118 | + | // yields domain.ErrAlreadyExists. The caller bounds the number of passkeys. | |
| 119 | + | func (s *Passkeys) Create(ctx context.Context, p domain.Passkey) error { | |
| 120 | + | val, err := encodePasskey(p) | |
| 121 | + | if err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | var b kv.Batch | |
| 125 | + | b.RequireAbsent(passkeyKey(p.ID)) | |
| 126 | + | b.Put(passkeyKey(p.ID), val) | |
| 127 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |
| 128 | + | if errors.Is(err, kv.ErrConflict) { | |
| 129 | + | return domain.ErrAlreadyExists | |
| 130 | + | } | |
| 131 | + | return fmt.Errorf("create passkey: %w", err) | |
| 132 | + | } | |
| 133 | + | return nil | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Update replaces a stored passkey, or returns domain.ErrNotFound. | |
| 137 | + | // | |
| 138 | + | //hexcheck:ignore suspicious-business-logic-in-adapter version-checked replace, not a policy | |
| 139 | + | func (s *Passkeys) Update(ctx context.Context, p domain.Passkey) error { | |
| 140 | + | val, err := encodePasskey(p) | |
| 141 | + | if err != nil { | |
| 142 | + | return err | |
| 143 | + | } | |
| 144 | + | for range updateAttempts { | |
| 145 | + | item, ok := s.db.Get(ctx, passkeyKey(p.ID)) | |
| 146 | + | if !ok { | |
| 147 | + | return domain.ErrNotFound | |
| 148 | + | } | |
| 149 | + | var b kv.Batch | |
| 150 | + | b.RequireVersion(passkeyKey(p.ID), item.Version) | |
| 151 | + | b.Put(passkeyKey(p.ID), val) | |
| 152 | + | _, err := s.db.Apply(ctx, &b) | |
| 153 | + | if errors.Is(err, kv.ErrConflict) { | |
| 154 | + | continue // changed or removed meanwhile: read again | |
| 155 | + | } | |
| 156 | + | if err != nil { | |
| 157 | + | return fmt.Errorf("update passkey: %w", err) | |
| 158 | + | } | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | return fmt.Errorf("update passkey: %w", domain.ErrConflict) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Delete removes passkey/cred/<id>, or returns domain.ErrNotFound. | |
| 165 | + | func (s *Passkeys) Delete(ctx context.Context, id []byte) error { | |
| 166 | + | if !validPasskeyID(id) { | |
| 167 | + | return domain.ErrNotFound | |
| 168 | + | } | |
| 169 | + | for range updateAttempts { | |
| 170 | + | item, ok := s.db.Get(ctx, passkeyKey(id)) | |
| 171 | + | if !ok { | |
| 172 | + | return domain.ErrNotFound | |
| 173 | + | } | |
| 174 | + | var b kv.Batch | |
| 175 | + | b.RequireVersion(passkeyKey(id), item.Version) | |
| 176 | + | b.Delete(passkeyKey(id)) | |
| 177 | + | _, err := s.db.Apply(ctx, &b) | |
| 178 | + | if errors.Is(err, kv.ErrConflict) { | |
| 179 | + | continue | |
| 180 | + | } | |
| 181 | + | if err != nil { | |
| 182 | + | return fmt.Errorf("delete passkey: %w", err) | |
| 183 | + | } | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | return fmt.Errorf("delete passkey: %w", domain.ErrConflict) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func encodePasskey(p domain.Passkey) ([]byte, error) { | |
| 190 | + | if !validPasskeyID(p.ID) { | |
| 191 | + | return nil, fmt.Errorf("%w: passkey id has %d bytes", domain.ErrInvalid, len(p.ID)) | |
| 192 | + | } | |
| 193 | + | if !utf8.ValidString(p.Name) || !utf8.ValidString(p.AttestationType) { | |
| 194 | + | return nil, fmt.Errorf("%w: passkey text is not valid UTF-8", domain.ErrInvalid) | |
| 195 | + | } | |
| 196 | + | for _, t := range p.Transports { | |
| 197 | + | if !utf8.ValidString(t) { | |
| 198 | + | return nil, fmt.Errorf("%w: passkey transport is not valid UTF-8", domain.ErrInvalid) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | return mustMarshal(&storagepb.Passkey{ | |
| 202 | + | Id: p.ID, | |
| 203 | + | Name: p.Name, | |
| 204 | + | PublicKey: p.PublicKey, | |
| 205 | + | AttestationType: p.AttestationType, | |
| 206 | + | Transports: p.Transports, | |
| 207 | + | SignCount: p.SignCount, | |
| 208 | + | BackupEligible: p.BackupEligible, | |
| 209 | + | BackupState: p.BackupState, | |
| 210 | + | CreatedAt: encodeTime(p.CreatedAt), | |
| 211 | + | LastUsedAt: encodeTime(p.LastUsedAt), | |
| 212 | + | }), nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func decodePasskey(val []byte) (domain.Passkey, error) { | |
| 216 | + | var pb storagepb.Passkey | |
| 217 | + | if err := proto.Unmarshal(val, &pb); err != nil { | |
| 218 | + | return domain.Passkey{}, fmt.Errorf("decode passkey: %w", err) | |
| 219 | + | } | |
| 220 | + | if !validPasskeyID(pb.GetId()) { | |
| 221 | + | return domain.Passkey{}, fmt.Errorf("decode passkey: id has %d bytes", len(pb.GetId())) | |
| 222 | + | } | |
| 223 | + | return domain.Passkey{ | |
| 224 | + | ID: bytes.Clone(pb.GetId()), | |
| 225 | + | Name: pb.GetName(), | |
| 226 | + | PublicKey: bytes.Clone(pb.GetPublicKey()), | |
| 227 | + | AttestationType: pb.GetAttestationType(), | |
| 228 | + | Transports: slices.Clone(pb.GetTransports()), | |
| 229 | + | SignCount: pb.GetSignCount(), | |
| 230 | + | BackupEligible: pb.GetBackupEligible(), | |
| 231 | + | BackupState: pb.GetBackupState(), | |
| 232 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 233 | + | LastUsedAt: decodeTime(pb.GetLastUsedAt()), | |
| 234 | + | }, nil | |
| 235 | + | } | |
A internal/adapters/kvstore/passkeys_test.go +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func newPasskey(id string, at time.Time) domain.Passkey { | |
| 19 | + | return domain.Passkey{ | |
| 20 | + | ID: []byte(id), | |
| 21 | + | Name: "key " + id, | |
| 22 | + | PublicKey: []byte("cose-" + id), | |
| 23 | + | AttestationType: "none", | |
| 24 | + | Transports: []string{"internal", "hybrid"}, | |
| 25 | + | SignCount: 7, | |
| 26 | + | BackupEligible: true, | |
| 27 | + | BackupState: true, | |
| 28 | + | CreatedAt: at, | |
| 29 | + | LastUsedAt: at.Add(time.Hour), | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestPasskeysRoundTrip(t *testing.T) { | |
| 34 | + | ctx := context.Background() | |
| 35 | + | ps := openStore(t, t.TempDir()).Passkeys() | |
| 36 | + | ||
| 37 | + | p := newPasskey("cred-1", created) | |
| 38 | + | require.NoError(t, ps.Create(ctx, p)) | |
| 39 | + | got, err := ps.Get(ctx, p.ID) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | assert.Equal(t, p, got) | |
| 42 | + | ||
| 43 | + | // Minimal passkey: no transports, never used. A binary id works too. | |
| 44 | + | min := domain.Passkey{ID: []byte{0, 1, 0xff, '/'}, Name: "min", PublicKey: []byte("k"), CreatedAt: created} | |
| 45 | + | require.NoError(t, ps.Create(ctx, min)) | |
| 46 | + | got, err = ps.Get(ctx, min.ID) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | assert.Empty(t, got.Transports) | |
| 49 | + | assert.True(t, got.LastUsedAt.IsZero()) | |
| 50 | + | assert.Equal(t, min.ID, got.ID) | |
| 51 | + | ||
| 52 | + | long := domain.Passkey{ID: bytes.Repeat([]byte{7}, domain.MaxPasskeyIDLen), Name: "long", PublicKey: []byte("k"), CreatedAt: created} | |
| 53 | + | require.NoError(t, ps.Create(ctx, long)) | |
| 54 | + | tooLong := domain.Passkey{ID: bytes.Repeat([]byte{7}, domain.MaxPasskeyIDLen+1), Name: "x", PublicKey: []byte("k")} | |
| 55 | + | assert.ErrorIs(t, ps.Create(ctx, tooLong), domain.ErrInvalid) | |
| 56 | + | assert.ErrorIs(t, ps.Create(ctx, domain.Passkey{Name: "no id"}), domain.ErrInvalid) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestPasskeysDuplicateAndUnknown(t *testing.T) { | |
| 60 | + | ctx := context.Background() | |
| 61 | + | ps := openStore(t, t.TempDir()).Passkeys() | |
| 62 | + | p := newPasskey("cred-1", created) | |
| 63 | + | require.NoError(t, ps.Create(ctx, p)) | |
| 64 | + | ||
| 65 | + | assert.ErrorIs(t, ps.Create(ctx, p), domain.ErrAlreadyExists) | |
| 66 | + | ||
| 67 | + | unknown := newPasskey("nope", created) | |
| 68 | + | _, err := ps.Get(ctx, unknown.ID) | |
| 69 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 70 | + | assert.ErrorIs(t, ps.Update(ctx, unknown), domain.ErrNotFound) | |
| 71 | + | assert.ErrorIs(t, ps.Delete(ctx, unknown.ID), domain.ErrNotFound) | |
| 72 | + | _, err = ps.Get(ctx, nil) | |
| 73 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 74 | + | assert.ErrorIs(t, ps.Delete(ctx, nil), domain.ErrNotFound) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestPasskeysUpdateDelete(t *testing.T) { | |
| 78 | + | ctx := context.Background() | |
| 79 | + | ps := openStore(t, t.TempDir()).Passkeys() | |
| 80 | + | p := newPasskey("cred-1", created) | |
| 81 | + | require.NoError(t, ps.Create(ctx, p)) | |
| 82 | + | ||
| 83 | + | p.SignCount = 42 | |
| 84 | + | p.BackupState = false | |
| 85 | + | p.LastUsedAt = created.Add(48 * time.Hour) | |
| 86 | + | require.NoError(t, ps.Update(ctx, p)) | |
| 87 | + | got, err := ps.Get(ctx, p.ID) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | assert.Equal(t, p, got) | |
| 90 | + | ||
| 91 | + | require.NoError(t, ps.Delete(ctx, p.ID)) | |
| 92 | + | _, err = ps.Get(ctx, p.ID) | |
| 93 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 94 | + | assert.ErrorIs(t, ps.Delete(ctx, p.ID), domain.ErrNotFound) | |
| 95 | + | list, err := ps.List(ctx) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | assert.Empty(t, list) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // List orders by creation time, then by id. | |
| 101 | + | func TestPasskeysListOrder(t *testing.T) { | |
| 102 | + | ctx := context.Background() | |
| 103 | + | ps := openStore(t, t.TempDir()).Passkeys() | |
| 104 | + | for _, p := range []domain.Passkey{ | |
| 105 | + | newPasskey("c", created.Add(2*time.Hour)), | |
| 106 | + | newPasskey("b", created), | |
| 107 | + | newPasskey("a", created), | |
| 108 | + | newPasskey("d", created.Add(time.Hour)), | |
| 109 | + | } { | |
| 110 | + | require.NoError(t, ps.Create(ctx, p)) | |
| 111 | + | } | |
| 112 | + | list, err := ps.List(ctx) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | var ids []string | |
| 115 | + | for _, p := range list { | |
| 116 | + | ids = append(ids, string(p.ID)) | |
| 117 | + | } | |
| 118 | + | assert.Equal(t, []string{"a", "b", "d", "c"}, ids) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // The handle is generated once, stays stable across a restart, and concurrent | |
| 122 | + | // first calls agree on it. | |
| 123 | + | func TestPasskeysUserHandle(t *testing.T) { | |
| 124 | + | ctx := context.Background() | |
| 125 | + | dir := t.TempDir() | |
| 126 | + | s := openStore(t, dir) | |
| 127 | + | ||
| 128 | + | var wg sync.WaitGroup | |
| 129 | + | handles := make([][]byte, 8) | |
| 130 | + | for i := range handles { | |
| 131 | + | wg.Add(1) | |
| 132 | + | go func() { | |
| 133 | + | defer wg.Done() | |
| 134 | + | h, err := s.Passkeys().UserHandle(ctx) | |
| 135 | + | assert.NoError(t, err) | |
| 136 | + | handles[i] = h | |
| 137 | + | }() | |
| 138 | + | } | |
| 139 | + | wg.Wait() | |
| 140 | + | handle := handles[0] | |
| 141 | + | assert.Len(t, handle, 32) | |
| 142 | + | for _, h := range handles[1:] { | |
| 143 | + | assert.Equal(t, handle, h) | |
| 144 | + | } | |
| 145 | + | require.NoError(t, s.Close(ctx)) | |
| 146 | + | ||
| 147 | + | s = openStore(t, dir) | |
| 148 | + | again, err := s.Passkeys().UserHandle(ctx) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | assert.Equal(t, handle, again, "a restart keeps the handle") | |
| 151 | + | } | |
M internal/adapters/rpc/api.go +16 -3
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "context" | |
| 11 | 11 | "net/http" | |
| 12 | 12 | "net/netip" | |
| 13 | + | "slices" | |
| 13 | 14 | ||
| 14 | 15 | "connectrpc.com/connect" | |
| 15 | 16 | "github.com/bnema/zerowrap" | |
| @@ -25,9 +26,13 @@ import ( | |||
| 25 | 26 | const MaxRequestBytes = 1 << 20 | |
| 26 | 27 | ||
| 27 | 28 | // Features are the optional capabilities of the contract that this server | |
| 28 | - | // implements, as GetVersion lists them. | |
| 29 | + | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 30 | + | // when Config.Passkeys is set. | |
| 29 | 31 | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"} | |
| 30 | 32 | ||
| 33 | + | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 34 | + | const FeaturePasskeys = "passkeys" | |
| 35 | + | ||
| 31 | 36 | // Config configures the API. | |
| 32 | 37 | type Config struct { | |
| 33 | 38 | Auth ports.AuthService | |
| @@ -55,6 +60,10 @@ type Config struct { | |||
| 55 | 60 | // Leave it unset rather than assigning a typed nil pointer (a nil *T in | |
| 56 | 61 | // the interface is not nil, and the service would be served and called). | |
| 57 | 62 | Sessions ports.SessionService | |
| 63 | + | // Passkeys serves ListPasskeys and RemovePasskey; nil means passkeys are | |
| 64 | + | // not available (the RPCs answer failed_precondition and GetVersion does | |
| 65 | + | // not list "passkeys"). Same rule as Sessions about typed nil pointers. | |
| 66 | + | Passkeys ports.PasskeyService | |
| 58 | 67 | } | |
| 59 | 68 | ||
| 60 | 69 | // API is the set of Connect handlers of the contract. | |
| @@ -81,10 +90,14 @@ func New(cfg Config) *API { | |||
| 81 | 90 | mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...)) | |
| 82 | 91 | mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...)) | |
| 83 | 92 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | |
| 84 | - | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...)) | |
| 93 | + | features := slices.Clone(Features) | |
| 94 | + | if cfg.Passkeys != nil { | |
| 95 | + | features = append(features, FeaturePasskeys) | |
| 96 | + | } | |
| 97 | + | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...)) | |
| 85 | 98 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | |
| 86 | 99 | if cfg.Sessions != nil { | |
| 87 | - | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...)) | |
| 100 | + | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...)) | |
| 88 | 101 | } | |
| 89 | 102 | if cfg.Runner != nil { | |
| 90 | 103 | mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...)) | |
M internal/adapters/rpc/convert.go +17 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package rpc | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "encoding/base64" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "slices" | |
| 8 | 9 | "time" | |
| @@ -411,3 +412,19 @@ func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility | |||
| 411 | 412 | } | |
| 412 | 413 | return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)} | |
| 413 | 414 | } | |
| 415 | + | ||
| 416 | + | // passkeysToPB converts passkeys; the credential id is base64url without | |
| 417 | + | // padding, the form RemovePasskey takes back. Key material never leaves. | |
| 418 | + | func passkeysToPB(list []domain.Passkey) []*pb.Passkey { | |
| 419 | + | out := make([]*pb.Passkey, 0, len(list)) | |
| 420 | + | for _, p := range list { | |
| 421 | + | out = append(out, &pb.Passkey{ | |
| 422 | + | Id: base64.RawURLEncoding.EncodeToString(p.ID), | |
| 423 | + | Name: p.Name, | |
| 424 | + | CreatedAt: timestamp(p.CreatedAt), | |
| 425 | + | LastUsedAt: timestamp(p.LastUsedAt), | |
| 426 | + | Synced: p.BackupState, | |
| 427 | + | }) | |
| 428 | + | } | |
| 429 | + | return out | |
| 430 | + | } | |
M internal/adapters/rpc/server.go +4 -3
| @@ -14,12 +14,13 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | // serverService implements ermiteconnect.ServerServiceHandler. | |
| 16 | 16 | type serverService struct { | |
| 17 | - | version string | |
| 18 | - | key ports.SigningKeyService | |
| 17 | + | version string | |
| 18 | + | features []string | |
| 19 | + | key ports.SigningKeyService | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) { | |
| 22 | - | return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil | |
| 23 | + | return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(s.features)}), nil | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | // GetSigningKey returns the public key of the commits ermite signs. It needs | |
M internal/adapters/rpc/server_test.go +27 -3
| @@ -4,28 +4,33 @@ package rpc | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "slices" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 10 | + | "net/http/httptest" | |
| 11 | + | ||
| 9 | 12 | "connectrpc.com/connect" | |
| 10 | 13 | "github.com/stretchr/testify/assert" | |
| 11 | 14 | "github.com/stretchr/testify/require" | |
| 12 | 15 | ||
| 13 | 16 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 14 | 18 | "bnema.dev/ermite/internal/domain" | |
| 15 | 19 | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | 20 | ) | |
| 17 | 21 | ||
| 18 | 22 | func TestServerServiceGetVersion(t *testing.T) { | |
| 19 | 23 | t.Parallel() | |
| 20 | - | svc := &serverService{version: "v1.2.3", key: mocks.NewSigningKeyService(t)} | |
| 24 | + | advertised := []string{"one", "two"} | |
| 25 | + | svc := &serverService{version: "v1.2.3", features: advertised, key: mocks.NewSigningKeyService(t)} | |
| 21 | 26 | resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |
| 22 | 27 | require.NoError(t, err) | |
| 23 | 28 | assert.Equal(t, "v1.2.3", resp.Msg.GetVersion()) | |
| 24 | - | assert.Equal(t, Features, resp.Msg.GetFeatures()) | |
| 29 | + | assert.Equal(t, []string{"one", "two"}, resp.Msg.GetFeatures()) | |
| 25 | 30 | ||
| 26 | 31 | // The caller gets a copy: changing it does not change what is advertised. | |
| 27 | 32 | resp.Msg.Features[0] = "tampered" | |
| 28 | - | assert.NotEqual(t, "tampered", Features[0]) | |
| 33 | + | assert.Equal(t, []string{"one", "two"}, advertised) | |
| 29 | 34 | } | |
| 30 | 35 | ||
| 31 | 36 | func TestServerServiceGetSigningKey(t *testing.T) { | |
| @@ -43,3 +48,22 @@ func TestServerServiceGetSigningKey(t *testing.T) { | |||
| 43 | 48 | assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail()) | |
| 44 | 49 | assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners()) | |
| 45 | 50 | } | |
| 51 | + | ||
| 52 | + | // GetVersion lists "passkeys" only when the API is built with passkeys. | |
| 53 | + | func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) { | |
| 54 | + | t.Parallel() | |
| 55 | + | list := func(cfg Config) []string { | |
| 56 | + | cfg.Sessions = mocks.NewSessionService(t) | |
| 57 | + | cfg.SigningKey = mocks.NewSigningKeyService(t) | |
| 58 | + | srv := httptest.NewServer(New(cfg).Handler()) | |
| 59 | + | t.Cleanup(srv.Close) | |
| 60 | + | c := ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL) | |
| 61 | + | resp, err := c.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | return resp.Msg.GetFeatures() | |
| 64 | + | } | |
| 65 | + | assert.Equal(t, Features, list(Config{})) | |
| 66 | + | got := list(Config{Passkeys: mocks.NewPasskeyService(t)}) | |
| 67 | + | assert.Equal(t, append(slices.Clone(Features), "passkeys"), got) | |
| 68 | + | assert.NotContains(t, Features, "passkeys", "the shared list is not modified") | |
| 69 | + | } | |
M internal/adapters/rpc/session.go +32 -9
| @@ -4,22 +4,28 @@ package rpc | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "encoding/base64" | |
| 7 | 8 | "errors" | |
| 9 | + | "fmt" | |
| 8 | 10 | ||
| 9 | 11 | "connectrpc.com/connect" | |
| 10 | 12 | ||
| 11 | 13 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | 15 | "bnema.dev/ermite/internal/ports" | |
| 13 | 16 | ) | |
| 14 | 17 | ||
| 15 | - | // errPasskeysUnavailable is what the passkey RPCs answer: this server has no | |
| 16 | - | // passkeys, which the contract reports as failed_precondition. The passkey | |
| 17 | - | // feature replaces these two methods. | |
| 18 | + | // errPasskeysUnavailable is what the passkey RPCs answer when the server runs | |
| 19 | + | // without passkeys (no https public URL with a DNS host name). | |
| 18 | 20 | var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server")) | |
| 19 | 21 | ||
| 20 | 22 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | |
| 21 | - | // never calls it: sessions come from the link, and the API ignores cookies. | |
| 22 | - | type sessionService struct{ sessions ports.SessionService } | |
| 23 | + | // never calls it: sessions come from the link or a passkey, and the API | |
| 24 | + | // ignores cookies. passkeys is nil when passkeys are not available. | |
| 25 | + | type sessionService struct { | |
| 26 | + | sessions ports.SessionService | |
| 27 | + | passkeys ports.PasskeyService | |
| 28 | + | } | |
| 23 | 29 | ||
| 24 | 30 | func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) { | |
| 25 | 31 | link, err := s.sessions.CreateLoginLink(ctx, caller(ctx)) | |
| @@ -36,10 +42,27 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque | |||
| 36 | 42 | return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil | |
| 37 | 43 | } | |
| 38 | 44 | ||
| 39 | - | func (s *sessionService) ListPasskeys(context.Context, *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) { | |
| 40 | - | return nil, errPasskeysUnavailable | |
| 45 | + | func (s *sessionService) ListPasskeys(ctx context.Context, _ *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) { | |
| 46 | + | if s.passkeys == nil { | |
| 47 | + | return nil, errPasskeysUnavailable | |
| 48 | + | } | |
| 49 | + | list, err := s.passkeys.List(ctx, caller(ctx)) | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, connectError(ctx, err) | |
| 52 | + | } | |
| 53 | + | return connect.NewResponse(&pb.ListPasskeysResponse{Passkeys: passkeysToPB(list)}), nil | |
| 41 | 54 | } | |
| 42 | 55 | ||
| 43 | - | func (s *sessionService) RemovePasskey(context.Context, *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) { | |
| 44 | - | return nil, errPasskeysUnavailable | |
| 56 | + | func (s *sessionService) RemovePasskey(ctx context.Context, req *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) { | |
| 57 | + | if s.passkeys == nil { | |
| 58 | + | return nil, errPasskeysUnavailable | |
| 59 | + | } | |
| 60 | + | id, err := base64.RawURLEncoding.DecodeString(req.Msg.GetId()) | |
| 61 | + | if err != nil || len(id) == 0 { | |
| 62 | + | return nil, connectError(ctx, fmt.Errorf("%w: id is not a base64url credential id", domain.ErrInvalid)) | |
| 63 | + | } | |
| 64 | + | if err := s.passkeys.Remove(ctx, caller(ctx), id); err != nil { | |
| 65 | + | return nil, connectError(ctx, err) | |
| 66 | + | } | |
| 67 | + | return connect.NewResponse(&pb.RemovePasskeyResponse{}), nil | |
| 45 | 68 | } | |
M internal/adapters/rpc/session_test.go +97 -2
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package rpc | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "encoding/base64" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -35,7 +36,7 @@ func TestSessionServiceCreateLoginLink(t *testing.T) { | |||
| 35 | 36 | sessions := mocks.NewSessionService(t) | |
| 36 | 37 | sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()). | |
| 37 | 38 | Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once() | |
| 38 | - | resp, err := (&sessionService{sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 39 | + | resp, err := (&sessionService{sessions: sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 39 | 40 | if tt.wantCode != 0 { | |
| 40 | 41 | requireCode(t, tt.wantCode, err) | |
| 41 | 42 | return | |
| @@ -62,7 +63,7 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) { | |||
| 62 | 63 | t.Parallel() | |
| 63 | 64 | sessions := mocks.NewSessionService(t) | |
| 64 | 65 | sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once() | |
| 65 | - | _, err := (&sessionService{sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |
| 66 | + | _, err := (&sessionService{sessions: sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |
| 66 | 67 | if tt.wantCode != 0 { | |
| 67 | 68 | requireCode(t, tt.wantCode, err) | |
| 68 | 69 | return | |
| @@ -71,3 +72,97 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) { | |||
| 71 | 72 | }) | |
| 72 | 73 | } | |
| 73 | 74 | } | |
| 75 | + | ||
| 76 | + | func TestSessionServiceListPasskeys(t *testing.T) { | |
| 77 | + | t.Parallel() | |
| 78 | + | created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 79 | + | used := created.Add(time.Hour) | |
| 80 | + | tests := []struct { | |
| 81 | + | name string | |
| 82 | + | list []domain.Passkey | |
| 83 | + | usecase error | |
| 84 | + | wantCode connect.Code | |
| 85 | + | }{ | |
| 86 | + | {name: "none", list: nil}, | |
| 87 | + | {name: "two", list: []domain.Passkey{ | |
| 88 | + | {ID: []byte{0xfb, 0xff, 0x01}, Name: "phone", PublicKey: []byte("secret"), CreatedAt: created, LastUsedAt: used, BackupState: true}, | |
| 89 | + | {ID: []byte("hw"), Name: "key", CreatedAt: created}, | |
| 90 | + | }}, | |
| 91 | + | {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |
| 92 | + | {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |
| 93 | + | } | |
| 94 | + | for _, tt := range tests { | |
| 95 | + | t.Run(tt.name, func(t *testing.T) { | |
| 96 | + | t.Parallel() | |
| 97 | + | passkeys := mocks.NewPasskeyService(t) | |
| 98 | + | passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once() | |
| 99 | + | resp, err := (&sessionService{passkeys: passkeys}).ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{})) | |
| 100 | + | if tt.wantCode != 0 { | |
| 101 | + | requireCode(t, tt.wantCode, err) | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.Len(t, resp.Msg.GetPasskeys(), len(tt.list)) | |
| 106 | + | for i, want := range tt.list { | |
| 107 | + | got := resp.Msg.GetPasskeys()[i] | |
| 108 | + | assert.Equal(t, base64.RawURLEncoding.EncodeToString(want.ID), got.GetId()) | |
| 109 | + | assert.Equal(t, want.Name, got.GetName()) | |
| 110 | + | assert.Equal(t, want.BackupState, got.GetSynced()) | |
| 111 | + | assert.True(t, want.CreatedAt.Equal(got.GetCreatedAt().AsTime())) | |
| 112 | + | if want.LastUsedAt.IsZero() { | |
| 113 | + | assert.Nil(t, got.GetLastUsedAt(), "never used") | |
| 114 | + | } else { | |
| 115 | + | assert.True(t, want.LastUsedAt.Equal(got.GetLastUsedAt().AsTime())) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestSessionServiceRemovePasskey(t *testing.T) { | |
| 123 | + | t.Parallel() | |
| 124 | + | id := []byte{0xfb, 0xff, 0x01, 'x'} | |
| 125 | + | enc := base64.RawURLEncoding.EncodeToString(id) | |
| 126 | + | tests := []struct { | |
| 127 | + | name string | |
| 128 | + | id string | |
| 129 | + | usecase error | |
| 130 | + | called bool | |
| 131 | + | wantCode connect.Code | |
| 132 | + | }{ | |
| 133 | + | {name: "ok", id: enc, called: true}, | |
| 134 | + | {name: "unknown id", id: enc, called: true, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound}, | |
| 135 | + | {name: "not the owner", id: enc, called: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |
| 136 | + | {name: "anonymous", id: enc, called: true, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |
| 137 | + | {name: "not base64url", id: "not base64!", wantCode: connect.CodeInvalidArgument}, | |
| 138 | + | {name: "padded base64", id: base64.URLEncoding.EncodeToString(id), wantCode: connect.CodeInvalidArgument}, | |
| 139 | + | {name: "standard alphabet", id: "+/8B", wantCode: connect.CodeInvalidArgument}, | |
| 140 | + | {name: "empty", id: "", wantCode: connect.CodeInvalidArgument}, | |
| 141 | + | } | |
| 142 | + | for _, tt := range tests { | |
| 143 | + | t.Run(tt.name, func(t *testing.T) { | |
| 144 | + | t.Parallel() | |
| 145 | + | passkeys := mocks.NewPasskeyService(t) | |
| 146 | + | if tt.called { | |
| 147 | + | passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(tt.usecase).Once() | |
| 148 | + | } | |
| 149 | + | _, err := (&sessionService{passkeys: passkeys}).RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: tt.id})) | |
| 150 | + | if tt.wantCode != 0 { | |
| 151 | + | requireCode(t, tt.wantCode, err) | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Without passkeys the RPCs answer failed_precondition and call nothing. | |
| 160 | + | func TestSessionServicePasskeysUnavailable(t *testing.T) { | |
| 161 | + | t.Parallel() | |
| 162 | + | svc := &sessionService{sessions: mocks.NewSessionService(t)} | |
| 163 | + | _, err := svc.ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{})) | |
| 164 | + | requireCode(t, connect.CodeFailedPrecondition, err) | |
| 165 | + | assert.Contains(t, err.Error(), "passkeys are not available on this server") | |
| 166 | + | _, err = svc.RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: "AQ"})) | |
| 167 | + | requireCode(t, connect.CodeFailedPrecondition, err) | |
| 168 | + | } | |
M internal/adapters/web/ASSETS.md +1 -0
| @@ -11,6 +11,7 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 11 | 11 | | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | | | |
| 12 | 12 | | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | | | |
| 13 | 13 | | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | | | |
| 14 | + | | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | | | |
| 14 | 15 | | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | | | |
| 15 | 16 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | |
| 16 | 17 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | |
M internal/adapters/web/assets.go +2 -1
| @@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/" | |||
| 23 | 23 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |
| 24 | 24 | // the content, so they can be cached for ever. | |
| 25 | 25 | type Assets struct { | |
| 26 | - | CSS, HTMX, Live, SSE, App, Files, Log, Icon string | |
| 26 | + | CSS, HTMX, Live, SSE, App, Files, Passkey, Log, Icon string | |
| 27 | 27 | // Font is the UI font, preloaded by every page. | |
| 28 | 28 | Font string | |
| 29 | 29 | // Rules is the speculation rules file named by the Speculation-Rules header. | |
| @@ -56,6 +56,7 @@ var scripts = []struct { | |||
| 56 | 56 | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | |
| 57 | 57 | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | |
| 58 | 58 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | |
| 59 | + | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | |
| 59 | 60 | {"log.js", func(a *Assets, u string) { a.Log = u }}, | |
| 60 | 61 | } | |
| 61 | 62 | ||
M internal/adapters/web/jstest/dom.mjs +3 -1
| @@ -143,10 +143,11 @@ export class El { | |||
| 143 | 143 | return null; | |
| 144 | 144 | } | |
| 145 | 145 | scrollIntoView() {} | |
| 146 | + | focus() { dom.focused.push(this); } | |
| 146 | 147 | click() { dom.clicked.push(this); } | |
| 147 | 148 | } | |
| 148 | 149 | ||
| 149 | - | export const dom = { clicked: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 }; | |
| 150 | + | export const dom = { clicked: [], focused: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 }; | |
| 150 | 151 | ||
| 151 | 152 | // flushFrames runs the frames held since the last flush. | |
| 152 | 153 | export function flushFrames() { | |
| @@ -160,6 +161,7 @@ export function flushFrames() { | |||
| 160 | 161 | export function installDom({ parseHTML } = {}) { | |
| 161 | 162 | dom.body = new El('body'); | |
| 162 | 163 | dom.clicked = []; | |
| 164 | + | dom.focused = []; | |
| 163 | 165 | const doc = { | |
| 164 | 166 | body: dom.body, | |
| 165 | 167 | createElement(tag) { | |
A internal/adapters/web/jstest/passkey.test.mjs +470 -0
| @@ -0,0 +1,470 @@ | |||
| 1 | + | // Tests of static/js/passkey.js on a tiny hand-rolled DOM, with fetch and | |
| 2 | + | // navigator.credentials stubbed. Run with | |
| 3 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 4 | + | import assert from 'node:assert/strict'; | |
| 5 | + | import { test as nodeTest, beforeEach, afterEach } from 'node:test'; | |
| 6 | + | import { dom, el, installDom } from './dom.mjs'; | |
| 7 | + | ||
| 8 | + | // Every test has two seconds: a ceremony that never settles fails the test | |
| 9 | + | // instead of hanging the run. | |
| 10 | + | const test = (title, fn) => nodeTest(title, { timeout: 2000 }, fn); | |
| 11 | + | ||
| 12 | + | const doc = installDom({ parseHTML: () => [] }); | |
| 13 | + | // The script registers its listeners when it loads: record them. | |
| 14 | + | const listeners = []; | |
| 15 | + | doc.addEventListener = (type, fn) => { listeners.push([type, fn]); }; | |
| 16 | + | // Node defines navigator as a getter: replace it, as a browser test would. | |
| 17 | + | const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true }); | |
| 18 | + | setNavigator({ credentials: {} }); | |
| 19 | + | globalThis.PublicKeyCredential = undefined; | |
| 20 | + | await import('../static/js/passkey.js'); | |
| 21 | + | const P = window.ermitePasskey; | |
| 22 | + | ||
| 23 | + | const OPTIONS = { publicKey: { challenge: 'abc' } }; | |
| 24 | + | const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) }; | |
| 25 | + | ||
| 26 | + | let fetches; // {url, init} | |
| 27 | + | let answers; // url -> {ok, status, body} (body undefined: not JSON) | |
| 28 | + | let parsed; // [kind, publicKey] | |
| 29 | + | let nav; // where the page was sent: 'assign:/' or 'reload' | |
| 30 | + | let status; | |
| 31 | + | let name; | |
| 32 | + | ||
| 33 | + | // page builds the DOM of the login page (a form with the sign-in button) or of | |
| 34 | + | // the passkeys page (a form with the name field and the add button). Both | |
| 35 | + | // buttons are submit buttons of a [data-passkey-form]. | |
| 36 | + | function page(kind) { | |
| 37 | + | dom.body = el('body'); | |
| 38 | + | dom.focused = []; | |
| 39 | + | status = el('p', { attrs: { 'data-passkey-status': '' } }); | |
| 40 | + | name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' }); | |
| 41 | + | const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish', type: 'submit' } }); | |
| 42 | + | const form = el('form', { attrs: { 'data-passkey-form': '' } }, ...(kind === 'register' ? [name, btn] : [btn])); | |
| 43 | + | dom.body.append(status, form); | |
| 44 | + | btn.form = form; | |
| 45 | + | return btn; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // pending holds the resolvers of the authenticator prompts a test left open; | |
| 49 | + | // afterEach answers them, so that no promise outlives its test. | |
| 50 | + | let pending = []; | |
| 51 | + | const prompt = (value) => new Promise((resolve) => { pending.push(() => resolve(value)); }); | |
| 52 | + | afterEach(() => { pending.forEach((release) => release()); pending = []; }); | |
| 53 | + | ||
| 54 | + | beforeEach(() => { | |
| 55 | + | fetches = []; | |
| 56 | + | parsed = []; | |
| 57 | + | nav = ''; | |
| 58 | + | answers = { | |
| 59 | + | '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }, | |
| 60 | + | '/finish': { ok: true, status: 204, body: undefined }, | |
| 61 | + | }; | |
| 62 | + | globalThis.fetch = async (url, init) => { | |
| 63 | + | fetches.push({ url, init }); | |
| 64 | + | const a = answers[url]; | |
| 65 | + | return { | |
| 66 | + | ok: a.ok, | |
| 67 | + | status: a.status, | |
| 68 | + | json: async () => { | |
| 69 | + | if (a.body === undefined) throw new SyntaxError('Unexpected end of JSON input'); | |
| 70 | + | return a.body; | |
| 71 | + | }, | |
| 72 | + | }; | |
| 73 | + | }; | |
| 74 | + | globalThis.PublicKeyCredential = { | |
| 75 | + | parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; }, | |
| 76 | + | parseCreationOptionsFromJSON: (o) => { parsed.push(['create', o]); return { parsedCreate: o }; }, | |
| 77 | + | }; | |
| 78 | + | setNavigator({ | |
| 79 | + | credentials: { | |
| 80 | + | get: async (o) => { navigator.got = o; return CRED; }, | |
| 81 | + | create: async (o) => { navigator.created = o; return CRED; }, | |
| 82 | + | }, | |
| 83 | + | }); | |
| 84 | + | globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } }; | |
| 85 | + | }); | |
| 86 | + | ||
| 87 | + | // submitOn sends a submit event from a form (a click on its submit button, or | |
| 88 | + | // Enter in a field). It answers the promise of the handler and whether the | |
| 89 | + | // browser's own navigation was kept (no preventDefault). | |
| 90 | + | const submitOn = (target) => { | |
| 91 | + | let prevented = false; | |
| 92 | + | const p = P.submit({ target, preventDefault() { prevented = true; } }); | |
| 93 | + | return { p, prevented: () => prevented }; | |
| 94 | + | }; | |
| 95 | + | const submitForm = (btn) => submitOn(btn.form).p; | |
| 96 | + | const jsonBody = (i) => JSON.parse(fetches[i].init.body); | |
| 97 | + | const beginCalls = () => fetches.filter((f) => f.url === '/begin').length; | |
| 98 | + | const tick = () => new Promise((r) => setTimeout(r, 0)); | |
| 99 | + | ||
| 100 | + | test('the script registers exactly one submit listener and no click listener', () => { | |
| 101 | + | assert.deepEqual(listeners.map(([t]) => t), ['submit']); | |
| 102 | + | assert.equal(listeners[0][1], P.submit); | |
| 103 | + | assert.deepEqual(Object.keys(P).sort(), ['init', 'nameProblem', 'submit'], 'only what the tests need is exported'); | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | test('login: begin, get, finish with toJSON(), then home', async () => { | |
| 107 | + | const btn = page('login'); | |
| 108 | + | const { p, prevented } = submitOn(btn.form); | |
| 109 | + | await p; | |
| 110 | + | ||
| 111 | + | assert.equal(prevented(), true, 'the form never navigates by itself'); | |
| 112 | + | assert.equal(fetches.length, 2); | |
| 113 | + | assert.equal(fetches[0].url, '/begin'); | |
| 114 | + | assert.equal(fetches[0].init.method, 'POST'); | |
| 115 | + | assert.equal(fetches[0].init.credentials, 'same-origin'); | |
| 116 | + | assert.equal(fetches[0].init.body, undefined, 'a sign-in sends no name'); | |
| 117 | + | assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]); | |
| 118 | + | assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } }); | |
| 119 | + | ||
| 120 | + | assert.equal(fetches[1].url, '/finish'); | |
| 121 | + | assert.equal(fetches[1].init.method, 'POST'); | |
| 122 | + | assert.equal(fetches[1].init.credentials, 'same-origin'); | |
| 123 | + | assert.equal(fetches[1].init.headers['Content-Type'], 'application/json'); | |
| 124 | + | assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } }); | |
| 125 | + | assert.equal(nav, 'assign:/'); | |
| 126 | + | assert.equal(btn.disabled, true, 'stays disabled while the page navigates'); | |
| 127 | + | }); | |
| 128 | + | ||
| 129 | + | test('register: the trimmed name goes to begin only, then reload', async () => { | |
| 130 | + | const btn = page('register'); | |
| 131 | + | const { p, prevented } = submitOn(btn.form); | |
| 132 | + | await p; | |
| 133 | + | ||
| 134 | + | assert.equal(prevented(), true); | |
| 135 | + | assert.equal(fetches[0].url, '/begin'); | |
| 136 | + | assert.equal(fetches[0].init.credentials, 'same-origin'); | |
| 137 | + | assert.equal(fetches[0].init.headers['Content-Type'], 'application/json'); | |
| 138 | + | assert.deepEqual(JSON.parse(fetches[0].init.body), { name: 'Pixel 9' }); | |
| 139 | + | assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]); | |
| 140 | + | assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } }); | |
| 141 | + | assert.equal(fetches[1].init.credentials, 'same-origin'); | |
| 142 | + | assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } }, 'the finish body has no name'); | |
| 143 | + | assert.equal(nav, 'reload'); | |
| 144 | + | }); | |
| 145 | + | ||
| 146 | + | test('a submit from another form is not ours and keeps its default', async () => { | |
| 147 | + | page('register'); | |
| 148 | + | const other = el('form', {}, el('input')); | |
| 149 | + | dom.body.append(other); | |
| 150 | + | const { p, prevented } = submitOn(other); | |
| 151 | + | await p; | |
| 152 | + | assert.equal(fetches.length, 0); | |
| 153 | + | assert.equal(prevented(), false, 'other forms submit as they always did'); | |
| 154 | + | }); | |
| 155 | + | ||
| 156 | + | // While the ceremony waits for the authenticator the button is busy and | |
| 157 | + | // disabled, the status says so, and a second submit starts nothing: the | |
| 158 | + | // disabled button is the guard. | |
| 159 | + | test('busy: aria-busy, the waiting text, and a double submit makes one begin', async () => { | |
| 160 | + | const btn = page('login'); | |
| 161 | + | navigator.credentials.get = () => prompt(CRED); | |
| 162 | + | ||
| 163 | + | const first = submitForm(btn); | |
| 164 | + | await tick(); | |
| 165 | + | assert.equal(btn.getAttribute('aria-busy'), 'true'); | |
| 166 | + | assert.equal(btn.disabled, true); | |
| 167 | + | assert.equal(status.textContent, 'waiting for your passkey…'); | |
| 168 | + | ||
| 169 | + | await submitForm(btn); // the double click, or Enter | |
| 170 | + | assert.equal(beginCalls(), 1, 'one begin only'); | |
| 171 | + | ||
| 172 | + | pending.forEach((release) => release()); | |
| 173 | + | await first; | |
| 174 | + | assert.equal(beginCalls(), 1); | |
| 175 | + | assert.equal(nav, 'assign:/'); | |
| 176 | + | }); | |
| 177 | + | ||
| 178 | + | test('a double submit of the add form makes one begin', async () => { | |
| 179 | + | const btn = page('register'); | |
| 180 | + | navigator.credentials.create = () => prompt(CRED); | |
| 181 | + | const first = submitForm(btn); | |
| 182 | + | await tick(); | |
| 183 | + | await submitForm(btn); | |
| 184 | + | assert.equal(beginCalls(), 1); | |
| 185 | + | pending.forEach((release) => release()); | |
| 186 | + | await first; | |
| 187 | + | assert.equal(nav, 'reload'); | |
| 188 | + | }); | |
| 189 | + | ||
| 190 | + | test('after a failure the page is free for another try', async () => { | |
| 191 | + | const btn = page('register'); | |
| 192 | + | answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } }; | |
| 193 | + | await submitForm(btn); | |
| 194 | + | assert.equal(status.textContent, 'nope'); | |
| 195 | + | assert.equal(btn.disabled, false); | |
| 196 | + | await submitForm(btn); | |
| 197 | + | assert.equal(beginCalls(), 2, 'the second try ran'); | |
| 198 | + | }); | |
| 199 | + | ||
| 200 | + | test('on failure the button is enabled again, not busy, and has the focus', async () => { | |
| 201 | + | const btn = page('login'); | |
| 202 | + | answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } }; | |
| 203 | + | await submitForm(btn); | |
| 204 | + | assert.equal(status.textContent, 'passkey not accepted'); | |
| 205 | + | assert.equal(btn.disabled, false); | |
| 206 | + | assert.equal(btn.hasAttribute('aria-busy'), false); | |
| 207 | + | assert.equal(nav, ''); | |
| 208 | + | assert.deepEqual(dom.focused, [btn], 'the focus is given back'); | |
| 209 | + | }); | |
| 210 | + | ||
| 211 | + | test('on success the button stays busy and disabled, without stealing focus', async () => { | |
| 212 | + | const btn = page('login'); | |
| 213 | + | await submitForm(btn); | |
| 214 | + | assert.equal(btn.disabled, true); | |
| 215 | + | assert.equal(btn.getAttribute('aria-busy'), 'true'); | |
| 216 | + | assert.deepEqual(dom.focused, []); | |
| 217 | + | }); | |
| 218 | + | ||
| 219 | + | test('begin refused: the server reason is shown, nothing else is called', async () => { | |
| 220 | + | const btn = page('login'); | |
| 221 | + | answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } }; | |
| 222 | + | await submitForm(btn); | |
| 223 | + | assert.equal(status.textContent, 'too many attempts'); | |
| 224 | + | assert.equal(fetches.length, 1); | |
| 225 | + | assert.equal(nav, ''); | |
| 226 | + | assert.equal(btn.disabled, false); | |
| 227 | + | assert.deepEqual(dom.focused, [btn]); | |
| 228 | + | }); | |
| 229 | + | ||
| 230 | + | // The server can refuse the name at begin: the field is what to fix, so it | |
| 231 | + | // gets the focus. Any other refusal gives the button the focus. | |
| 232 | + | test('a 400 from register/begin focuses the name field', async () => { | |
| 233 | + | const btn = page('register'); | |
| 234 | + | answers['/begin'] = { ok: false, status: 400, body: { error: 'passkey name: text contains the control character U+0009' } }; | |
| 235 | + | await submitForm(btn); | |
| 236 | + | assert.match(status.textContent, /control character/); | |
| 237 | + | assert.equal(navigator.created, undefined, 'create() is not called'); | |
| 238 | + | assert.equal(btn.disabled, false); | |
| 239 | + | assert.deepEqual(dom.focused, [name]); | |
| 240 | + | }); | |
| 241 | + | ||
| 242 | + | test('other refusals of register give the focus back to the button', async () => { | |
| 243 | + | for (const [at, code] of [['/begin', 429], ['/begin', 403], ['/begin', 500], ['/finish', 400], ['/finish', 409]]) { | |
| 244 | + | const btn = page('register'); | |
| 245 | + | answers = { | |
| 246 | + | '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }, | |
| 247 | + | '/finish': { ok: true, status: 204, body: undefined }, | |
| 248 | + | [at]: { ok: false, status: code, body: { error: 'refused' } }, | |
| 249 | + | }; | |
| 250 | + | await submitForm(btn); | |
| 251 | + | assert.deepEqual(dom.focused, [btn], at + ' ' + code); | |
| 252 | + | } | |
| 253 | + | }); | |
| 254 | + | ||
| 255 | + | test('a 400 at begin on the login page focuses the button: there is no name', async () => { | |
| 256 | + | const btn = page('login'); | |
| 257 | + | answers['/begin'] = { ok: false, status: 400, body: { error: 'malformed request' } }; | |
| 258 | + | await submitForm(btn); | |
| 259 | + | assert.deepEqual(dom.focused, [btn]); | |
| 260 | + | }); | |
| 261 | + | ||
| 262 | + | test('a refusal without a JSON body falls back to the status', async () => { | |
| 263 | + | const btn = page('login'); | |
| 264 | + | answers['/finish'] = { ok: false, status: 502, body: undefined }; | |
| 265 | + | await submitForm(btn); | |
| 266 | + | assert.equal(status.textContent, 'the server answered 502'); | |
| 267 | + | assert.equal(nav, ''); | |
| 268 | + | }); | |
| 269 | + | ||
| 270 | + | test('a 403 without a reason says the session ended', async () => { | |
| 271 | + | for (const body of [undefined, { error: 'forbidden' }, {}]) { | |
| 272 | + | fetches = []; | |
| 273 | + | const btn = page('register'); | |
| 274 | + | answers['/begin'] = { ok: false, status: 403, body }; | |
| 275 | + | await submitForm(btn); | |
| 276 | + | assert.equal(status.textContent, 'your session ended or this page is out of date; reload and sign in again'); | |
| 277 | + | assert.equal(fetches.length, 1); | |
| 278 | + | } | |
| 279 | + | // At finish too. | |
| 280 | + | const btn = page('login'); | |
| 281 | + | answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }; | |
| 282 | + | answers['/finish'] = { ok: false, status: 403, body: undefined }; | |
| 283 | + | await submitForm(btn); | |
| 284 | + | assert.match(status.textContent, /your session ended/); | |
| 285 | + | }); | |
| 286 | + | ||
| 287 | + | test('a 403 with a reason of its own shows that reason', async () => { | |
| 288 | + | const btn = page('register'); | |
| 289 | + | answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } }; | |
| 290 | + | await submitForm(btn); | |
| 291 | + | assert.equal(status.textContent, 'the owner must sign in first'); | |
| 292 | + | }); | |
| 293 | + | ||
| 294 | + | test('the owner cancels the prompt', async () => { | |
| 295 | + | const btn = page('login'); | |
| 296 | + | navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); }; | |
| 297 | + | await submitForm(btn); | |
| 298 | + | assert.match(status.textContent, /cancelled, or it took too long/); | |
| 299 | + | assert.equal(fetches.length, 1, 'finish is not called'); | |
| 300 | + | assert.equal(btn.disabled, false); | |
| 301 | + | assert.deepEqual(dom.focused, [btn]); | |
| 302 | + | }); | |
| 303 | + | ||
| 304 | + | test('AbortError says cancelled', async () => { | |
| 305 | + | const btn = page('login'); | |
| 306 | + | navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); }; | |
| 307 | + | await submitForm(btn); | |
| 308 | + | assert.equal(status.textContent, 'cancelled'); | |
| 309 | + | assert.equal(fetches.length, 1); | |
| 310 | + | assert.equal(btn.disabled, false); | |
| 311 | + | }); | |
| 312 | + | ||
| 313 | + | test('an authenticator that already holds a passkey', async () => { | |
| 314 | + | const btn = page('register'); | |
| 315 | + | navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); }; | |
| 316 | + | await submitForm(btn); | |
| 317 | + | assert.match(status.textContent, /already holds a passkey/); | |
| 318 | + | assert.equal(fetches.length, 1); | |
| 319 | + | }); | |
| 320 | + | ||
| 321 | + | test('a network failure is reported', async () => { | |
| 322 | + | const btn = page('login'); | |
| 323 | + | globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); }; | |
| 324 | + | await submitForm(btn); | |
| 325 | + | assert.equal(status.textContent, 'Failed to fetch'); | |
| 326 | + | assert.equal(btn.disabled, false); | |
| 327 | + | }); | |
| 328 | + | ||
| 329 | + | test('a null credential is not sent', async () => { | |
| 330 | + | const btn = page('login'); | |
| 331 | + | navigator.credentials.get = async () => null; | |
| 332 | + | await submitForm(btn); | |
| 333 | + | assert.equal(status.textContent, 'no passkey was returned'); | |
| 334 | + | assert.equal(fetches.length, 1); | |
| 335 | + | }); | |
| 336 | + | ||
| 337 | + | test('a credential without toJSON is reported, not sent', async () => { | |
| 338 | + | for (const kind of ['login', 'register']) { | |
| 339 | + | fetches = []; | |
| 340 | + | const btn = page(kind); | |
| 341 | + | const bare = { id: 'x' }; | |
| 342 | + | navigator.credentials.get = async () => bare; | |
| 343 | + | navigator.credentials.create = async () => bare; | |
| 344 | + | await submitForm(btn); | |
| 345 | + | assert.match(status.textContent, /cannot send; update the browser/); | |
| 346 | + | assert.equal(fetches.length, 1, 'finish is not called'); | |
| 347 | + | assert.equal(btn.disabled, false); | |
| 348 | + | } | |
| 349 | + | }); | |
| 350 | + | ||
| 351 | + | // The begin answer is checked before it reaches the browser API. | |
| 352 | + | test('a begin answer that is not JSON', async () => { | |
| 353 | + | const btn = page('login'); | |
| 354 | + | answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON | |
| 355 | + | await submitForm(btn); | |
| 356 | + | assert.equal(status.textContent, 'the server sent an unexpected answer'); | |
| 357 | + | assert.equal(parsed.length, 0); | |
| 358 | + | assert.equal(btn.disabled, false); | |
| 359 | + | }); | |
| 360 | + | ||
| 361 | + | test('a begin answer with the wrong shape', async () => { | |
| 362 | + | for (const body of [null, 'x', [], {}, { ceremony: 'c1' }, { ceremony: 'c1', options: {} }, { ceremony: 'c1', options: { publicKey: 'x' } }, | |
| 363 | + | { ceremony: '', options: OPTIONS }, { ceremony: 5, options: OPTIONS }, { options: OPTIONS }, { ceremony: 'c1', options: null }]) { | |
| 364 | + | fetches = []; | |
| 365 | + | parsed = []; | |
| 366 | + | const btn = page('login'); | |
| 367 | + | answers['/begin'] = { ok: true, status: 200, body }; | |
| 368 | + | await submitForm(btn); | |
| 369 | + | assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body)); | |
| 370 | + | assert.equal(parsed.length, 0, 'nothing reached the browser API'); | |
| 371 | + | assert.equal(fetches.length, 1); | |
| 372 | + | } | |
| 373 | + | }); | |
| 374 | + | ||
| 375 | + | // A name the server would refuse is caught before the owner is prompted. | |
| 376 | + | test('a name over 64 bytes (UTF-8) stops before begin', async () => { | |
| 377 | + | const btn = page('register'); | |
| 378 | + | name.value = 'é'.repeat(33); // 33 characters, 66 bytes | |
| 379 | + | await submitForm(btn); | |
| 380 | + | assert.match(status.textContent, /longer than 64 bytes/); | |
| 381 | + | assert.equal(fetches.length, 0, 'no request'); | |
| 382 | + | assert.equal(navigator.created, undefined, 'create() is not called'); | |
| 383 | + | assert.notEqual(btn.disabled, true, 'the button was never disabled'); | |
| 384 | + | assert.deepEqual(dom.focused, [name], 'the field gets the focus'); | |
| 385 | + | // The check is not a ceremony: the next try is not blocked. | |
| 386 | + | name.value = 'ok'; | |
| 387 | + | await submitForm(btn); | |
| 388 | + | assert.equal(fetches.length, 2); | |
| 389 | + | }); | |
| 390 | + | ||
| 391 | + | test('64 bytes is fine, in ASCII or in two-byte characters', async () => { | |
| 392 | + | for (const ok of ['a'.repeat(64), 'é'.repeat(32)]) { | |
| 393 | + | fetches = []; | |
| 394 | + | const btn = page('register'); // a success navigates away: each try is a new page | |
| 395 | + | name.value = ok; | |
| 396 | + | await submitForm(btn); | |
| 397 | + | assert.equal(fetches.length, 2, ok.length + ' characters'); | |
| 398 | + | } | |
| 399 | + | }); | |
| 400 | + | ||
| 401 | + | test('control and direction characters stop before begin', async () => { | |
| 402 | + | for (const bad of ['a\u0000b', 'a\tb', 'a\nb', 'a\x7fb', 'a\u0085b', 'evil \u202e txt', 'a\u2066b', 'a\u200eb', 'a\u061cb', 'a\u2028b', 'a\u2029b']) { | |
| 403 | + | fetches = []; | |
| 404 | + | const btn = page('register'); | |
| 405 | + | name.value = bad; | |
| 406 | + | await submitForm(btn); | |
| 407 | + | assert.match(status.textContent, /control or direction character/, JSON.stringify(bad)); | |
| 408 | + | assert.equal(fetches.length, 0, JSON.stringify(bad)); | |
| 409 | + | } | |
| 410 | + | }); | |
| 411 | + | ||
| 412 | + | test('plain text with a zero width joiner, accents and emoji is fine', () => { | |
| 413 | + | for (const ok of ['Pixel 9', 'clé de ma mère', 'a\u200db', '🔑 key', '']) { | |
| 414 | + | assert.equal(P.nameProblem(ok), '', JSON.stringify(ok)); | |
| 415 | + | } | |
| 416 | + | }); | |
| 417 | + | ||
| 418 | + | test('an empty name is allowed: the server names the passkey', async () => { | |
| 419 | + | const btn = page('register'); | |
| 420 | + | name.value = ' '; | |
| 421 | + | await submitForm(btn); | |
| 422 | + | assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' }); | |
| 423 | + | assert.equal(fetches.length, 2); | |
| 424 | + | }); | |
| 425 | + | ||
| 426 | + | test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => { | |
| 427 | + | const login = page('login'); | |
| 428 | + | const register = el('button', { attrs: { 'data-passkey': 'register' } }); | |
| 429 | + | dom.body.append(register); | |
| 430 | + | globalThis.PublicKeyCredential = {}; // no parse*OptionsFromJSON | |
| 431 | + | P.init(); | |
| 432 | + | assert.equal(login.disabled, true); | |
| 433 | + | assert.equal(register.disabled, true); | |
| 434 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 435 | + | ||
| 436 | + | login.disabled = false; | |
| 437 | + | await submitForm(login); | |
| 438 | + | assert.equal(fetches.length, 0); | |
| 439 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 440 | + | }); | |
| 441 | + | ||
| 442 | + | test('no PublicKeyCredential at all', () => { | |
| 443 | + | const btn = page('login'); | |
| 444 | + | globalThis.PublicKeyCredential = undefined; | |
| 445 | + | P.init(); | |
| 446 | + | assert.equal(btn.disabled, true); | |
| 447 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 448 | + | }); | |
| 449 | + | ||
| 450 | + | test('a supported browser keeps the buttons enabled', () => { | |
| 451 | + | const btn = page('login'); | |
| 452 | + | P.init(); | |
| 453 | + | assert.equal(btn.disabled, undefined); | |
| 454 | + | assert.equal(status.textContent, ''); | |
| 455 | + | }); | |
| 456 | + | ||
| 457 | + | test('a disabled button ignores a submit', async () => { | |
| 458 | + | const btn = page('login'); | |
| 459 | + | btn.disabled = true; | |
| 460 | + | await submitForm(btn); | |
| 461 | + | assert.equal(fetches.length, 0); | |
| 462 | + | }); | |
| 463 | + | ||
| 464 | + | test('register needs parseCreationOptionsFromJSON', async () => { | |
| 465 | + | const btn = page('register'); | |
| 466 | + | delete PublicKeyCredential.parseCreationOptionsFromJSON; | |
| 467 | + | await submitForm(btn); | |
| 468 | + | assert.equal(fetches.length, 0); | |
| 469 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 470 | + | }); | |
M internal/adapters/web/pages.templ +86 -0
| @@ -183,3 +183,89 @@ templ Login(l Layout, p LoginPage) { | |||
| 183 | 183 | </section> | |
| 184 | 184 | } | |
| 185 | 185 | } | |
| 186 | + | ||
| 187 | + | // PasskeyLogin is the sign-in page: a button the script (passkey.js) drives. | |
| 188 | + | // Without script the button is hidden and the page says what to do instead. | |
| 189 | + | templ PasskeyLogin(l Layout, p PasskeyLoginPage) { | |
| 190 | + | @Shell(l, nil) { | |
| 191 | + | <section class="state" aria-labelledby="state-title"> | |
| 192 | + | <h1 id="state-title">{ p.Title }</h1> | |
| 193 | + | <form class="state-act" data-js data-passkey-form> | |
| 194 | + | <button type="submit" class="btn primary" data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button> | |
| 195 | + | </form> | |
| 196 | + | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | |
| 197 | + | for _, line := range p.Lines { | |
| 198 | + | <p class="dim">{ line }</p> | |
| 199 | + | } | |
| 200 | + | <p class="state-back"><a href="/">Back to home</a></p> | |
| 201 | + | </section> | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // Passkeys lists the owner's passkeys, removes them and adds one. | |
| 206 | + | templ Passkeys(l Layout, p PasskeysPage) { | |
| 207 | + | @Shell(l, nil) { | |
| 208 | + | <h2 class="rule-h"><span>Passkeys</span><span class="count">{ itoa(len(p.Rows)) }</span></h2> | |
| 209 | + | <p class="dim">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p> | |
| 210 | + | if len(p.Rows) > 0 { | |
| 211 | + | <table class="list passkeys" aria-label="Passkeys"> | |
| 212 | + | <thead> | |
| 213 | + | <tr> | |
| 214 | + | <th class="cur"></th> | |
| 215 | + | <th class="c-name">Name</th> | |
| 216 | + | <th class="c-when">Created</th> | |
| 217 | + | <th class="c-when">Last used</th> | |
| 218 | + | <th class="c-sync">Synced</th> | |
| 219 | + | <th class="c-act"><span class="sr">Remove</span></th> | |
| 220 | + | </tr> | |
| 221 | + | </thead> | |
| 222 | + | <tbody> | |
| 223 | + | for _, r := range p.Rows { | |
| 224 | + | <tr> | |
| 225 | + | <td class="cur" aria-hidden="true"></td> | |
| 226 | + | <td class="c-name">{ r.Name }</td> | |
| 227 | + | <td class="c-when nowrap"> | |
| 228 | + | <time | |
| 229 | + | if r.CreatedAt != "" { | |
| 230 | + | datetime={ r.CreatedAt } | |
| 231 | + | } | |
| 232 | + | >{ r.Created }</time> | |
| 233 | + | </td> | |
| 234 | + | <td class="c-when nowrap"> | |
| 235 | + | <time | |
| 236 | + | if r.LastUsedAt != "" { | |
| 237 | + | datetime={ r.LastUsedAt } | |
| 238 | + | } | |
| 239 | + | >{ r.LastUsed }</time> | |
| 240 | + | </td> | |
| 241 | + | <td class="c-sync"> | |
| 242 | + | if r.Synced { | |
| 243 | + | yes | |
| 244 | + | } else { | |
| 245 | + | no | |
| 246 | + | } | |
| 247 | + | </td> | |
| 248 | + | <td class="c-act"> | |
| 249 | + | <form method="post" action={ templ.SafeURL(r.Remove) }> | |
| 250 | + | <button type="submit" class="btn danger" aria-label={ "Remove passkey " + r.Name }>Remove</button> | |
| 251 | + | </form> | |
| 252 | + | </td> | |
| 253 | + | </tr> | |
| 254 | + | } | |
| 255 | + | </tbody> | |
| 256 | + | </table> | |
| 257 | + | } else { | |
| 258 | + | <p class="lede dim">no passkeys yet.</p> | |
| 259 | + | } | |
| 260 | + | if p.Full { | |
| 261 | + | <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p> | |
| 262 | + | } else { | |
| 263 | + | <form class="passkey-add" data-js data-passkey-form> | |
| 264 | + | <label for="passkey-name" class="sr">Name of the new passkey</label> | |
| 265 | + | <input id="passkey-name" type="text" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/> | |
| 266 | + | <button type="submit" class="btn primary" data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button> | |
| 267 | + | </form> | |
| 268 | + | } | |
| 269 | + | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | |
| 270 | + | } | |
| 271 | + | } | |
M internal/adapters/web/pages_templ.go +390 -0 generated file: collapsed
A internal/adapters/web/passkeys.go +329 -0
| @@ -0,0 +1,329 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "io" | |
| 11 | + | "mime" | |
| 12 | + | "net/http" | |
| 13 | + | "strconv" | |
| 14 | + | ||
| 15 | + | "github.com/a-h/templ" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // The owner's passkeys on the web (ADR 0026). | |
| 24 | + | // | |
| 25 | + | // The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the | |
| 26 | + | // owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js | |
| 27 | + | // makes, each in two steps (begin, finish). Every POST passes the gate: an | |
| 28 | + | // Origin equal to the public origin, and a session, except the two login | |
| 29 | + | // endpoints, which are how a session starts. The finish endpoints also need | |
| 30 | + | // "Content-Type: application/json", which a cross-site form cannot send. | |
| 31 | + | ||
| 32 | + | const ( | |
| 33 | + | passkeyLoginPath = "/-/login" | |
| 34 | + | passkeysPath = "/-/passkeys" | |
| 35 | + | ||
| 36 | + | loginBeginPath = passkeysPath + "/login/begin" | |
| 37 | + | loginFinishPath = passkeysPath + "/login/finish" | |
| 38 | + | ||
| 39 | + | // maxCeremonyBytes bounds the body of a finish: a response of an | |
| 40 | + | // authenticator is well under 10 KiB. | |
| 41 | + | maxCeremonyBytes = 64 << 10 | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // passkeysOn tells that the passkey routes exist. | |
| 45 | + | func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil } | |
| 46 | + | ||
| 47 | + | func (s *Server) passkeyRoutes(mux *http.ServeMux) { | |
| 48 | + | mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage) | |
| 49 | + | mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin) | |
| 50 | + | mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish) | |
| 51 | + | mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage)) | |
| 52 | + | mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin) | |
| 53 | + | mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish) | |
| 54 | + | mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // isPasskeyLoginPath tells whether u is exactly one of the two endpoints an | |
| 58 | + | // anonymous browser may post to. Like isLoginPath it looks at the escaped path, | |
| 59 | + | // which is what the router matches. | |
| 60 | + | func isPasskeyLoginPath(path string) bool { | |
| 61 | + | return path == loginBeginPath || path == loginFinishPath | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // jsonReply writes a JSON answer that no cache keeps. | |
| 65 | + | func jsonReply(w http.ResponseWriter, status int, v any) { | |
| 66 | + | h := w.Header() | |
| 67 | + | h.Set("Content-Type", "application/json") | |
| 68 | + | h.Set("Cache-Control", noStore) | |
| 69 | + | w.WriteHeader(status) | |
| 70 | + | _ = json.NewEncoder(w).Encode(v) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | type jsonMessage struct { | |
| 74 | + | Error string `json:"error"` | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func jsonError(w http.ResponseWriter, status int, msg string) { | |
| 78 | + | jsonReply(w, status, jsonMessage{Error: msg}) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // jsonInternal answers a failure that is not the caller's to fix: an id for the | |
| 82 | + | // log, and no detail. | |
| 83 | + | func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |
| 84 | + | id := requestID() | |
| 85 | + | log := zerowrap.FromCtx(ctx) | |
| 86 | + | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed") | |
| 87 | + | jsonError(w, http.StatusInternalServerError, "internal error, request id "+id) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // jsonBody checks the content type of a finish and reads its body, bounded. It | |
| 91 | + | // answers 415 or 413 and returns false when it cannot. | |
| 92 | + | func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { | |
| 93 | + | mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) | |
| 94 | + | if err != nil || mt != "application/json" { | |
| 95 | + | jsonError(w, http.StatusUnsupportedMediaType, "send application/json") | |
| 96 | + | return nil, false | |
| 97 | + | } | |
| 98 | + | body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes)) | |
| 99 | + | if err != nil { | |
| 100 | + | var tooBig *http.MaxBytesError | |
| 101 | + | if errors.As(err, &tooBig) { | |
| 102 | + | jsonError(w, http.StatusRequestEntityTooLarge, "request too large") | |
| 103 | + | } else { | |
| 104 | + | jsonError(w, http.StatusBadRequest, "request could not be read") | |
| 105 | + | } | |
| 106 | + | return nil, false | |
| 107 | + | } | |
| 108 | + | return body, true | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // ceremonyReply is the answer of a begin. | |
| 112 | + | type ceremonyReply struct { | |
| 113 | + | Ceremony string `json:"ceremony"` | |
| 114 | + | Options json.RawMessage `json:"options"` | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // blocked answers 429 when the client has failed too many sign-ins. | |
| 118 | + | func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) { | |
| 119 | + | addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies) | |
| 120 | + | if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is { | |
| 121 | + | w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) | |
| 122 | + | jsonError(w, http.StatusTooManyRequests, "too many attempts") | |
| 123 | + | return addr, true | |
| 124 | + | } | |
| 125 | + | return addr, false | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes | |
| 129 | + | // home. | |
| 130 | + | func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) { | |
| 131 | + | if SignedIn(r.Context()) { | |
| 132 | + | seeOther(w, r, "/") | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}}) | |
| 136 | + | s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{ | |
| 137 | + | Title: "sign in", | |
| 138 | + | Button: "Sign in with a passkey", | |
| 139 | + | Lines: []string{"or run `ermite web login` in a terminal."}, | |
| 140 | + | Begin: loginBeginPath, | |
| 141 | + | Finish: loginFinishPath, | |
| 142 | + | })) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // passkeyLoginBegin is POST /-/passkeys/login/begin. | |
| 146 | + | func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) { | |
| 147 | + | addr, blocked := s.blocked(w, r) | |
| 148 | + | if blocked { | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 152 | + | defer cancel() | |
| 153 | + | ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx, clientlimit.Key(addr)) | |
| 154 | + | if err != nil { | |
| 155 | + | s.jsonInternal(ctx, w, r, err) | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options}) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // finishRequest is the body of a finish. The name of a new passkey is given at | |
| 162 | + | // the begin of its registration, not here. | |
| 163 | + | type finishRequest struct { | |
| 164 | + | Ceremony string `json:"ceremony"` | |
| 165 | + | Credential json.RawMessage `json:"credential"` | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // readFinish reads the body of a finish: JSON, bounded, with a ceremony and a | |
| 169 | + | // credential. It answers 415, 413 or 400 and returns false when it cannot. A | |
| 170 | + | // body that fails here is a bug of the client, not a guess at a passkey: the | |
| 171 | + | // callers do not count it as a failed sign-in. | |
| 172 | + | func readFinish(w http.ResponseWriter, r *http.Request) (finishRequest, bool) { | |
| 173 | + | body, ok := jsonBody(w, r) | |
| 174 | + | if !ok { | |
| 175 | + | return finishRequest{}, false | |
| 176 | + | } | |
| 177 | + | var req finishRequest | |
| 178 | + | if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 { | |
| 179 | + | jsonError(w, http.StatusBadRequest, "malformed request") | |
| 180 | + | return finishRequest{}, false | |
| 181 | + | } | |
| 182 | + | return req, true | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response | |
| 186 | + | // of the authenticator and sets the session cookie, the same as a login link. | |
| 187 | + | // A refusal counts as a failed authentication of the client, whatever the | |
| 188 | + | // reason. | |
| 189 | + | func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { | |
| 190 | + | // The limiter first: a client that is blocked gets 429 whatever it sends. | |
| 191 | + | addr, blocked := s.blocked(w, r) | |
| 192 | + | if blocked { | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | req, ok := readFinish(w, r) | |
| 196 | + | if !ok { | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 200 | + | defer cancel() | |
| 201 | + | token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential) | |
| 202 | + | switch { | |
| 203 | + | case errors.Is(err, ports.ErrLinkRejected): | |
| 204 | + | s.cfg.Limiter.Fail(ctx, addr) | |
| 205 | + | jsonError(w, http.StatusUnauthorized, "passkey not accepted") | |
| 206 | + | return | |
| 207 | + | case err != nil: | |
| 208 | + | s.jsonInternal(ctx, w, r, err) | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | h := w.Header() | |
| 212 | + | h.Del("Set-Cookie") // a stale cookie cleared by withSession | |
| 213 | + | http.SetCookie(w, s.cookie(token, int(lifetime.Seconds()))) | |
| 214 | + | h.Set("Cache-Control", noStore) | |
| 215 | + | w.WriteHeader(http.StatusNoContent) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404 | |
| 219 | + | // page, as for any page that does not exist for them. | |
| 220 | + | func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) { | |
| 221 | + | if !SignedIn(ctx) { | |
| 222 | + | return nil, 0, errNotFound | |
| 223 | + | } | |
| 224 | + | list, err := s.cfg.Passkeys.List(ctx, principal(ctx)) | |
| 225 | + | if err != nil { | |
| 226 | + | return nil, 0, err | |
| 227 | + | } | |
| 228 | + | now := s.cfg.Now() | |
| 229 | + | rows := make([]PasskeyRow, 0, len(list)) | |
| 230 | + | for _, p := range list { | |
| 231 | + | rows = append(rows, PasskeyRow{ | |
| 232 | + | Name: p.Name, | |
| 233 | + | Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt), | |
| 234 | + | LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt), | |
| 235 | + | Synced: p.BackupState, | |
| 236 | + | Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove", | |
| 237 | + | }) | |
| 238 | + | } | |
| 239 | + | l := s.base() | |
| 240 | + | l.Title = "passkeys · " + s.instance | |
| 241 | + | l.Crumbs = []Crumb{{Label: "passkeys", Current: true}} | |
| 242 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |
| 243 | + | return Passkeys(l, PasskeysPage{ | |
| 244 | + | Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys, | |
| 245 | + | Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish", | |
| 246 | + | }), http.StatusOK, nil | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // registerError answers a refused registration step. | |
| 250 | + | func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |
| 251 | + | switch { | |
| 252 | + | case errors.Is(err, context.Canceled): | |
| 253 | + | // The browser is gone. | |
| 254 | + | case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx): | |
| 255 | + | jsonError(w, http.StatusForbidden, "forbidden") | |
| 256 | + | case errors.Is(err, domain.ErrAlreadyExists): | |
| 257 | + | jsonError(w, http.StatusConflict, "this passkey is already registered") | |
| 258 | + | case errors.Is(err, domain.ErrResourceExhausted): | |
| 259 | + | jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached")) | |
| 260 | + | case domain.IsInvalid(err): | |
| 261 | + | jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid")) | |
| 262 | + | case errors.Is(err, domain.ErrUnauthenticated): | |
| 263 | + | jsonError(w, http.StatusBadRequest, "passkey not accepted, try again") | |
| 264 | + | default: | |
| 265 | + | s.jsonInternal(ctx, w, r, err) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // passkeyRegisterBegin is POST /-/passkeys/register/begin. Like the finish | |
| 270 | + | // endpoints it needs "Content-Type: application/json". | |
| 271 | + | func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) { | |
| 272 | + | body, ok := jsonBody(w, r) | |
| 273 | + | if !ok { | |
| 274 | + | return | |
| 275 | + | } | |
| 276 | + | // {"name": "..."}; an empty body is no name. The name is checked now, before | |
| 277 | + | // the browser prompts the owner for a passkey that would then be refused. | |
| 278 | + | var req struct { | |
| 279 | + | Name string `json:"name"` | |
| 280 | + | } | |
| 281 | + | if len(body) > 0 { | |
| 282 | + | if err := json.Unmarshal(body, &req); err != nil { | |
| 283 | + | jsonError(w, http.StatusBadRequest, "malformed request") | |
| 284 | + | return | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 288 | + | defer cancel() | |
| 289 | + | ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx), req.Name) | |
| 290 | + | if err != nil { | |
| 291 | + | s.registerError(ctx, w, r, err) | |
| 292 | + | return | |
| 293 | + | } | |
| 294 | + | jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options}) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // passkeyRegisterFinish is POST /-/passkeys/register/finish. | |
| 298 | + | func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) { | |
| 299 | + | req, ok := readFinish(w, r) | |
| 300 | + | if !ok { | |
| 301 | + | return | |
| 302 | + | } | |
| 303 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 304 | + | defer cancel() | |
| 305 | + | if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Credential); err != nil { | |
| 306 | + | s.registerError(ctx, w, r, err) | |
| 307 | + | return | |
| 308 | + | } | |
| 309 | + | w.Header().Set("Cache-Control", noStore) | |
| 310 | + | w.WriteHeader(http.StatusNoContent) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which | |
| 314 | + | // signs every browser out, this one included: the answer sends it to the | |
| 315 | + | // sign-in page. | |
| 316 | + | func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) { | |
| 317 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 318 | + | defer cancel() | |
| 319 | + | id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id")) | |
| 320 | + | if err != nil || len(id) == 0 { | |
| 321 | + | s.actionFailed(ctx, w, r, errNotFound, passkeysPath) | |
| 322 | + | return | |
| 323 | + | } | |
| 324 | + | if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil { | |
| 325 | + | s.actionFailed(ctx, w, r, err, passkeysPath) | |
| 326 | + | return | |
| 327 | + | } | |
| 328 | + | seeOther(w, r, passkeyLoginPath) | |
| 329 | + | } | |
A internal/adapters/web/passkeys_actions_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Passkeys add an anonymous way to start a session, and the Actions pages add | |
| 19 | + | // POSTs that cancel and re-run runs. The two must not meet: the only POSTs an | |
| 20 | + | // anonymous browser may make are the two passkey login endpoints (and a login | |
| 21 | + | // link), so the run actions still need the Origin of the public URL and a | |
| 22 | + | // session, whether that session came from a link or from a passkey. | |
| 23 | + | func TestPasskeysDoNotOpenTheActionsPosts(t *testing.T) { | |
| 24 | + | t.Parallel() | |
| 25 | + | repo := repoName(t, "proj") | |
| 26 | + | runs := mocks.NewRunService(t) // a call the gate should have stopped fails the test | |
| 27 | + | sessions := newSessions(t, "tok") | |
| 28 | + | srv := newTestServer(t, Config{ | |
| 29 | + | Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs, | |
| 30 | + | PublicURL: forgeOrigin, Sessions: sessions, Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{}, | |
| 31 | + | Now: func() time.Time { return fixedNow }, | |
| 32 | + | }) | |
| 33 | + | f := &passkeyFixture{srv: srv} | |
| 34 | + | actions := []string{ | |
| 35 | + | "/proj/actions/runs/1/cancel", "/proj/actions/runs/1/rerun", "/proj/actions/runs/1/rerun-failed", | |
| 36 | + | } | |
| 37 | + | do := func(target, origin, cookie string) *httptest.ResponseRecorder { | |
| 38 | + | return f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/x-www-form-urlencoded"}) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for _, target := range actions { | |
| 42 | + | // Anonymous, with or without the right Origin, and with a cookie the | |
| 43 | + | // sessions do not know: forbidden, before any use case runs. | |
| 44 | + | for _, tc := range []struct{ origin, cookie string }{ | |
| 45 | + | {forgeOrigin, ""}, {"", ""}, {forgeOrigin, "stale"}, | |
| 46 | + | } { | |
| 47 | + | rec := do(target, tc.origin, tc.cookie) | |
| 48 | + | assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, tc.origin, tc.cookie) | |
| 49 | + | } | |
| 50 | + | // A signed-in browser from another origin: forbidden as well. | |
| 51 | + | for _, origin := range []string{"", "null", "https://evil.example"} { | |
| 52 | + | assert.Equal(t, http.StatusForbidden, do(target, origin, "tok").Code, "%s origin=%q", target, origin) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // The same paths are not passkey endpoints: lookalikes of the anonymous | |
| 57 | + | // ones do not pass either. | |
| 58 | + | for _, target := range []string{"/proj/actions/runs/1/cancel/-/passkeys/login/begin", "/-/passkeys/login/begin/../../proj/actions/runs/1/cancel"} { | |
| 59 | + | assert.NotEqual(t, http.StatusSeeOther, do(target, forgeOrigin, "").Code, target) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // A browser signed in (by a link or by a passkey, the cookie is the same) | |
| 63 | + | // reaches the use case as the owner, once per action. | |
| 64 | + | owner := mock.MatchedBy(func(p domain.Principal) bool { return p.IsOwner() }) | |
| 65 | + | runs.EXPECT().CancelRun(mock.Anything, owner, repo, int64(1)).Return(domain.CIRun{}, nil).Once() | |
| 66 | + | runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), false).Return(domain.CIRun{}, nil).Once() | |
| 67 | + | runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), true).Return(domain.CIRun{}, nil).Once() | |
| 68 | + | for _, target := range actions { | |
| 69 | + | rec := do(target, forgeOrigin, "tok") | |
| 70 | + | assert.Equal(t, http.StatusSeeOther, rec.Code, target) | |
| 71 | + | assert.Equal(t, "/proj/actions/runs/1", rec.Header().Get("Location"), target) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // The refusal of the use case still shows as the page of a forbidden action | |
| 76 | + | // for a signed-in browser. | |
| 77 | + | func TestPasskeySessionRefusedByTheRunUseCase(t *testing.T) { | |
| 78 | + | t.Parallel() | |
| 79 | + | repo := repoName(t, "proj") | |
| 80 | + | runs := mocks.NewRunService(t) | |
| 81 | + | runs.EXPECT().CancelRun(mock.Anything, mock.Anything, repo, int64(1)).Return(domain.CIRun{}, domain.ErrForbidden).Once() | |
| 82 | + | srv := newTestServer(t, Config{ | |
| 83 | + | Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs, | |
| 84 | + | PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{}, | |
| 85 | + | }) | |
| 86 | + | f := &passkeyFixture{srv: srv} | |
| 87 | + | rec := f.do(t, req{method: "POST", target: "/proj/actions/runs/1/cancel", origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"}) | |
| 88 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 89 | + | } | |
A internal/adapters/web/passkeys_test.go +648 -0
| @@ -0,0 +1,648 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "errors" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | const forgeOrigin = "https://forge.example" | |
| 25 | + | ||
| 26 | + | // blockingLimiter is a FailureLimiter that can be told to block. | |
| 27 | + | type blockingLimiter struct { | |
| 28 | + | blocked bool | |
| 29 | + | fails int | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) { | |
| 33 | + | if l.blocked { | |
| 34 | + | return true, 90 * time.Second | |
| 35 | + | } | |
| 36 | + | return false, 0 | |
| 37 | + | } | |
| 38 | + | func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ } | |
| 39 | + | ||
| 40 | + | type passkeyFixture struct { | |
| 41 | + | srv *Server | |
| 42 | + | sessions *mocks.WebSessionService | |
| 43 | + | passkeys *mocks.WebPasskeyService | |
| 44 | + | lim *blockingLimiter | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func newPasskeyServer(t *testing.T) *passkeyFixture { | |
| 48 | + | t.Helper() | |
| 49 | + | f := &passkeyFixture{ | |
| 50 | + | sessions: newSessions(t, "tok"), | |
| 51 | + | passkeys: mocks.NewWebPasskeyService(t), | |
| 52 | + | lim: &blockingLimiter{}, | |
| 53 | + | } | |
| 54 | + | f.srv = newTestServer(t, Config{ | |
| 55 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |
| 56 | + | PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim, | |
| 57 | + | Now: func() time.Time { return fixedNow }, | |
| 58 | + | }) | |
| 59 | + | return f | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // fixedNow is the clock of the passkey pages: "3d ago" does not depend on when | |
| 63 | + | // the test runs. | |
| 64 | + | var fixedNow = time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) | |
| 65 | + | ||
| 66 | + | type req struct { | |
| 67 | + | method, target string | |
| 68 | + | origin string // "" sends none | |
| 69 | + | cookie string | |
| 70 | + | contentType string | |
| 71 | + | body string | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder { | |
| 75 | + | t.Helper() | |
| 76 | + | hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body)) | |
| 77 | + | hr.Host = "forge.example" | |
| 78 | + | if r.origin != "" { | |
| 79 | + | hr.Header.Set("Origin", r.origin) | |
| 80 | + | } | |
| 81 | + | if r.cookie != "" { | |
| 82 | + | hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie}) | |
| 83 | + | } | |
| 84 | + | if r.contentType != "" { | |
| 85 | + | hr.Header.Set("Content-Type", r.contentType) | |
| 86 | + | } | |
| 87 | + | rec := httptest.NewRecorder() | |
| 88 | + | f.srv.ServeHTTP(rec, hr) | |
| 89 | + | return rec | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func postJSON(target, cookie, body string) req { | |
| 93 | + | return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body} | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | const finishBody = `{"ceremony":"c1","credential":{"id":"x","type":"public-key"}}` | |
| 97 | + | ||
| 98 | + | var ( | |
| 99 | + | beginLogin = "/-/passkeys/login/begin" | |
| 100 | + | finishLogin = "/-/passkeys/login/finish" | |
| 101 | + | beginRegister = "/-/passkeys/register/begin" | |
| 102 | + | finishRegister = "/-/passkeys/register/finish" | |
| 103 | + | ) | |
| 104 | + | ||
| 105 | + | // Every new POST needs the Origin of the public URL. | |
| 106 | + | func TestPasskeyPostsNeedTheOrigin(t *testing.T) { | |
| 107 | + | t.Parallel() | |
| 108 | + | f := newPasskeyServer(t) // the mocks fail the test on any call | |
| 109 | + | removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove" | |
| 110 | + | for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} { | |
| 111 | + | for _, cookie := range []string{"", "tok"} { | |
| 112 | + | for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} { | |
| 113 | + | rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody}) | |
| 114 | + | assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie) | |
| 115 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // An anonymous browser may post to the two login endpoints only. | |
| 122 | + | func TestPasskeyAnonymousPostsAreExact(t *testing.T) { | |
| 123 | + | t.Parallel() | |
| 124 | + | f := newPasskeyServer(t) | |
| 125 | + | id := base64.RawURLEncoding.EncodeToString([]byte("a")) | |
| 126 | + | for _, target := range []string{ | |
| 127 | + | beginRegister, finishRegister, "/-/passkeys/" + id + "/remove", | |
| 128 | + | "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x", | |
| 129 | + | "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin", | |
| 130 | + | "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin", | |
| 131 | + | "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f", | |
| 132 | + | } { | |
| 133 | + | rec := f.do(t, postJSON(target, "", finishBody)) | |
| 134 | + | assert.Equal(t, http.StatusForbidden, rec.Code, target) | |
| 135 | + | } | |
| 136 | + | // A GET is never a login. | |
| 137 | + | rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin}) | |
| 138 | + | assert.NotEqual(t, http.StatusOK, rec.Code) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestPasskeyLoginBegin(t *testing.T) { | |
| 142 | + | t.Parallel() | |
| 143 | + | f := newPasskeyServer(t) | |
| 144 | + | f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once() | |
| 145 | + | ||
| 146 | + | rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) | |
| 147 | + | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| 148 | + | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |
| 149 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 150 | + | assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String()) | |
| 151 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestPasskeyLoginBeginFailure(t *testing.T) { | |
| 155 | + | t.Parallel() | |
| 156 | + | f := newPasskeyServer(t) | |
| 157 | + | f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("", nil, errors.New("secret detail")).Once() | |
| 158 | + | rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) | |
| 159 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 160 | + | assert.NotContains(t, rec.Body.String(), "secret detail") | |
| 161 | + | assert.Contains(t, rec.Body.String(), "request id") | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestPasskeyLoginBlocked(t *testing.T) { | |
| 165 | + | t.Parallel() | |
| 166 | + | f := newPasskeyServer(t) // no use case call is expected | |
| 167 | + | f.lim.blocked = true | |
| 168 | + | // Blocked answers 429 whatever the request holds: the limiter comes before | |
| 169 | + | // the content type and the body are looked at. | |
| 170 | + | for _, r := range []req{ | |
| 171 | + | {method: "POST", target: beginLogin, origin: forgeOrigin}, | |
| 172 | + | postJSON(finishLogin, "", finishBody), | |
| 173 | + | {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"}, | |
| 174 | + | postJSON(finishLogin, "", `not json`), | |
| 175 | + | postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)), | |
| 176 | + | } { | |
| 177 | + | rec := f.do(t, r) | |
| 178 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code, "%s %q", r.target, r.contentType) | |
| 179 | + | assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String()) | |
| 180 | + | assert.Equal(t, "91", rec.Header().Get("Retry-After")) | |
| 181 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 182 | + | } | |
| 183 | + | assert.Zero(t, f.lim.fails, "a blocked client is not counted again") | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func TestPasskeyLoginFinishSetsTheSession(t *testing.T) { | |
| 187 | + | t.Parallel() | |
| 188 | + | f := newPasskeyServer(t) | |
| 189 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool { | |
| 190 | + | return string(b) == `{"id":"x","type":"public-key"}` | |
| 191 | + | })).Return("tok", 12*time.Hour, nil).Once() | |
| 192 | + | ||
| 193 | + | rec := f.do(t, postJSON(finishLogin, "", finishBody)) | |
| 194 | + | require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) | |
| 195 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 196 | + | assert.Zero(t, f.lim.fails) | |
| 197 | + | ||
| 198 | + | cookies := rec.Result().Cookies() | |
| 199 | + | require.Len(t, cookies, 1) | |
| 200 | + | c := cookies[0] | |
| 201 | + | assert.Equal(t, CookieName, c.Name) | |
| 202 | + | assert.Equal(t, "tok", c.Value) | |
| 203 | + | assert.True(t, c.Secure) | |
| 204 | + | assert.True(t, c.HttpOnly) | |
| 205 | + | assert.Equal(t, http.SameSiteStrictMode, c.SameSite) | |
| 206 | + | assert.Equal(t, "/", c.Path) | |
| 207 | + | assert.Equal(t, 12*3600, c.MaxAge) | |
| 208 | + | assert.Empty(t, c.Domain) | |
| 209 | + | set := rec.Header().Get("Set-Cookie") | |
| 210 | + | for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} { | |
| 211 | + | assert.Contains(t, set, attr) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // A stale cookie that withSession cleared is replaced, not doubled. | |
| 216 | + | func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) { | |
| 217 | + | t.Parallel() | |
| 218 | + | f := newPasskeyServer(t) | |
| 219 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once() | |
| 220 | + | rec := f.do(t, postJSON(finishLogin, "stale", finishBody)) | |
| 221 | + | // A stale cookie is not a session: the login endpoint still lets it through. | |
| 222 | + | require.Equal(t, http.StatusNoContent, rec.Code) | |
| 223 | + | require.Len(t, rec.Result().Cookies(), 1) | |
| 224 | + | assert.Equal(t, "tok", rec.Result().Cookies()[0].Value) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestPasskeyLoginFinishRefusal(t *testing.T) { | |
| 228 | + | t.Parallel() | |
| 229 | + | f := newPasskeyServer(t) | |
| 230 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice() | |
| 231 | + | ||
| 232 | + | for range 2 { | |
| 233 | + | rec := f.do(t, postJSON(finishLogin, "", finishBody)) | |
| 234 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 235 | + | assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String()) | |
| 236 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 237 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 238 | + | } | |
| 239 | + | assert.Equal(t, 2, f.lim.fails, "every refusal counts") | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func TestPasskeyLoginFinishInternalError(t *testing.T) { | |
| 243 | + | t.Parallel() | |
| 244 | + | f := newPasskeyServer(t) | |
| 245 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once() | |
| 246 | + | rec := f.do(t, postJSON(finishLogin, "", finishBody)) | |
| 247 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 248 | + | assert.NotContains(t, rec.Body.String(), "kv down") | |
| 249 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 250 | + | assert.Zero(t, f.lim.fails, "a server failure is not the client's") | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestPasskeyFinishNeedsJSON(t *testing.T) { | |
| 254 | + | t.Parallel() | |
| 255 | + | f := newPasskeyServer(t) // no use case call is expected | |
| 256 | + | for _, target := range []string{finishLogin, finishRegister} { | |
| 257 | + | for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} { | |
| 258 | + | cookie := "" | |
| 259 | + | if target == finishRegister { | |
| 260 | + | cookie = "tok" | |
| 261 | + | } | |
| 262 | + | rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody}) | |
| 263 | + | assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | assert.Zero(t, f.lim.fails) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) { | |
| 270 | + | t.Parallel() | |
| 271 | + | f := newPasskeyServer(t) | |
| 272 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once() | |
| 273 | + | rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody}) | |
| 274 | + | assert.Equal(t, http.StatusNoContent, rec.Code) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func TestPasskeyFinishBodyLimit(t *testing.T) { | |
| 278 | + | t.Parallel() | |
| 279 | + | f := newPasskeyServer(t) | |
| 280 | + | big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}` | |
| 281 | + | for _, target := range []string{finishLogin, finishRegister} { | |
| 282 | + | rec := f.do(t, postJSON(target, "tok", big)) | |
| 283 | + | assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target) | |
| 284 | + | } | |
| 285 | + | // Right at the limit is read (and then refused as malformed, not as too large). | |
| 286 | + | exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}` | |
| 287 | + | require.Len(t, exact, 64<<10) | |
| 288 | + | rec := f.do(t, postJSON(finishLogin, "", exact)) | |
| 289 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestPasskeyFinishMalformed(t *testing.T) { | |
| 293 | + | t.Parallel() | |
| 294 | + | f := newPasskeyServer(t) | |
| 295 | + | for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} { | |
| 296 | + | for _, target := range []string{finishLogin, finishRegister} { | |
| 297 | + | rec := f.do(t, postJSON(target, "tok", body)) | |
| 298 | + | assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body) | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // The registration endpoints are the owner's. | |
| 304 | + | func TestPasskeyRegistration(t *testing.T) { | |
| 305 | + | t.Parallel() | |
| 306 | + | f := newPasskeyServer(t) | |
| 307 | + | f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer9", []byte(`{"publicKey":{}}`), nil).Once() | |
| 308 | + | rec := f.do(t, postJSON(beginRegister, "tok", `{}`)) | |
| 309 | + | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| 310 | + | assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String()) | |
| 311 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 312 | + | ||
| 313 | + | f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", mock.MatchedBy(func(b []byte) bool { | |
| 314 | + | return string(b) == `{"id":"x","type":"public-key"}` | |
| 315 | + | })).Return(nil).Once() | |
| 316 | + | rec = f.do(t, postJSON(finishRegister, "tok", finishBody)) | |
| 317 | + | assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) | |
| 318 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 319 | + | assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session") | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func TestPasskeyRegistrationErrors(t *testing.T) { | |
| 323 | + | t.Parallel() | |
| 324 | + | for name, tc := range map[string]struct { | |
| 325 | + | err error | |
| 326 | + | status int | |
| 327 | + | body string | |
| 328 | + | }{ | |
| 329 | + | "duplicate": {domain.ErrAlreadyExists, 409, "already registered"}, | |
| 330 | + | "limit": {domain.ErrResourceExhausted, 429, ""}, | |
| 331 | + | "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"}, | |
| 332 | + | "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"}, | |
| 333 | + | "not owner": {domain.ErrForbidden, 403, ""}, | |
| 334 | + | "server error": {errors.New("kv down"), 500, ""}, | |
| 335 | + | } { | |
| 336 | + | t.Run(name, func(t *testing.T) { | |
| 337 | + | t.Parallel() | |
| 338 | + | f := newPasskeyServer(t) | |
| 339 | + | f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once() | |
| 340 | + | rec := f.do(t, postJSON(finishRegister, "tok", finishBody)) | |
| 341 | + | assert.Equal(t, tc.status, rec.Code) | |
| 342 | + | assert.Contains(t, rec.Body.String(), `"error"`) | |
| 343 | + | assert.Contains(t, rec.Body.String(), tc.body) | |
| 344 | + | assert.NotContains(t, rec.Body.String(), "kv down") | |
| 345 | + | assert.Zero(t, f.lim.fails, "the owner's errors are not login failures") | |
| 346 | + | }) | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | f := newPasskeyServer(t) | |
| 350 | + | f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once() | |
| 351 | + | rec := f.do(t, postJSON(beginRegister, "tok", `{}`)) | |
| 352 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func TestPasskeysPage(t *testing.T) { | |
| 356 | + | t.Parallel() | |
| 357 | + | f := newPasskeyServer(t) | |
| 358 | + | now := fixedNow | |
| 359 | + | list := []domain.Passkey{ | |
| 360 | + | {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true}, | |
| 361 | + | {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)}, | |
| 362 | + | } | |
| 363 | + | f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once() | |
| 364 | + | ||
| 365 | + | rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) | |
| 366 | + | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| 367 | + | assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) | |
| 368 | + | body := rec.Body.String() | |
| 369 | + | assert.Contains(t, body, "Pixel <9>", "names are escaped") | |
| 370 | + | assert.NotContains(t, body, "Pixel <9>") | |
| 371 | + | assert.Contains(t, body, "yubikey") | |
| 372 | + | assert.Contains(t, body, "3d ago") | |
| 373 | + | assert.Contains(t, body, "never") | |
| 374 | + | assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`) | |
| 375 | + | assert.Contains(t, body, `data-passkey="register"`) | |
| 376 | + | // The name field and the add button are one form, so Enter submits. | |
| 377 | + | start := strings.Index(body, "<form class=\"passkey-add\"") | |
| 378 | + | require.GreaterOrEqual(t, start, 0, "the add form is on the page") | |
| 379 | + | form := body[start:] | |
| 380 | + | end := strings.Index(form, "</form>") | |
| 381 | + | require.GreaterOrEqual(t, end, 0) | |
| 382 | + | form = form[:end] | |
| 383 | + | assert.Contains(t, form, "data-passkey-form") | |
| 384 | + | assert.Contains(t, form, `data-passkey-name`) | |
| 385 | + | assert.Contains(t, form, `type="submit"`) | |
| 386 | + | assert.Contains(t, form, `data-passkey="register"`) | |
| 387 | + | assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself") | |
| 388 | + | assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`) | |
| 389 | + | assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`) | |
| 390 | + | assert.Contains(t, body, "data-passkey-status") | |
| 391 | + | assert.Contains(t, body, `aria-live="polite"`) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func TestPasskeysPageFull(t *testing.T) { | |
| 395 | + | t.Parallel() | |
| 396 | + | f := newPasskeyServer(t) | |
| 397 | + | list := make([]domain.Passkey, domain.MaxPasskeys) | |
| 398 | + | for i := range list { | |
| 399 | + | list[i] = domain.Passkey{ID: []byte{byte(i + 1)}, Name: "k", CreatedAt: fixedNow} | |
| 400 | + | } | |
| 401 | + | f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(list, nil).Once() | |
| 402 | + | rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) | |
| 403 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 404 | + | assert.NotContains(t, rec.Body.String(), `data-passkey="register"`) | |
| 405 | + | assert.Contains(t, rec.Body.String(), "Remove one to add another") | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // Anybody but the owner gets the 404 page, and the list is not read. | |
| 409 | + | func TestPasskeysPageIsTheOwners(t *testing.T) { | |
| 410 | + | t.Parallel() | |
| 411 | + | f := newPasskeyServer(t) | |
| 412 | + | for _, cookie := range []string{"", "stale"} { | |
| 413 | + | rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: cookie}) | |
| 414 | + | assert.Equal(t, http.StatusNotFound, rec.Code, cookie) | |
| 415 | + | assert.NotContains(t, rec.Body.String(), "Passkeys") | |
| 416 | + | assert.NotContains(t, rec.Body.String(), "data-passkey") | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func TestPasskeyRemove(t *testing.T) { | |
| 421 | + | t.Parallel() | |
| 422 | + | f := newPasskeyServer(t) | |
| 423 | + | id := []byte{0xfb, 0xff, 'a'} | |
| 424 | + | target := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString(id) + "/remove" | |
| 425 | + | f.passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(nil).Once() | |
| 426 | + | ||
| 427 | + | rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"}) | |
| 428 | + | assert.Equal(t, http.StatusSeeOther, rec.Code) | |
| 429 | + | assert.Equal(t, "/-/login", rec.Header().Get("Location"), "the epoch bump signed this browser out too") | |
| 430 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | func TestPasskeyRemoveErrors(t *testing.T) { | |
| 434 | + | t.Parallel() | |
| 435 | + | f := newPasskeyServer(t) | |
| 436 | + | // Ids that are not unpadded base64url never reach the use case (the mock | |
| 437 | + | // fails the test on a call). | |
| 438 | + | for _, id := range []string{"not%20base64%21", "%2B%2F8B", "YQ%3D%3D", "%C3%A9"} { | |
| 439 | + | rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + id + "/remove", origin: forgeOrigin, cookie: "tok"}) | |
| 440 | + | assert.Equal(t, http.StatusNotFound, rec.Code, id) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("zz")).Return(domain.ErrNotFound).Once() | |
| 444 | + | rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("zz")) + "/remove", origin: forgeOrigin, cookie: "tok"}) | |
| 445 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 446 | + | ||
| 447 | + | f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("yy")).Return(errors.New("kv down")).Once() | |
| 448 | + | rec = f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("yy")) + "/remove", origin: forgeOrigin, cookie: "tok"}) | |
| 449 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 450 | + | assert.NotContains(t, rec.Body.String(), "kv down") | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func TestPasskeyLoginPage(t *testing.T) { | |
| 454 | + | t.Parallel() | |
| 455 | + | f := newPasskeyServer(t) | |
| 456 | + | rec := f.do(t, req{method: "GET", target: "/-/login"}) | |
| 457 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 458 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 459 | + | assert.Equal(t, "strict-origin", rec.Header().Get("Referrer-Policy")) | |
| 460 | + | body := rec.Body.String() | |
| 461 | + | assert.Contains(t, body, "<title>sign in") | |
| 462 | + | assert.Contains(t, body, "Sign in with a passkey") | |
| 463 | + | assert.Contains(t, body, `data-passkey="login"`) | |
| 464 | + | // One entry point for the script: the button is the submit button of a form. | |
| 465 | + | start := strings.Index(body, "<form class=\"state-act\"") | |
| 466 | + | require.GreaterOrEqual(t, start, 0, "the sign-in form is on the page") | |
| 467 | + | form := body[start:] | |
| 468 | + | end := strings.Index(form, "</form>") | |
| 469 | + | require.GreaterOrEqual(t, end, 0) | |
| 470 | + | form = form[:end] | |
| 471 | + | assert.Contains(t, form, "data-passkey-form") | |
| 472 | + | assert.Contains(t, form, `type="submit"`) | |
| 473 | + | assert.Contains(t, form, `data-passkey="login"`) | |
| 474 | + | assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself") | |
| 475 | + | assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`) | |
| 476 | + | assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`) | |
| 477 | + | assert.Contains(t, body, "ermite web login") | |
| 478 | + | assert.NotContains(t, body, "<script>", "no inline script") | |
| 479 | + | ||
| 480 | + | // A signed-in visitor goes home. | |
| 481 | + | rec = f.do(t, req{method: "GET", target: "/-/login", cookie: "tok"}) | |
| 482 | + | assert.Equal(t, http.StatusSeeOther, rec.Code) | |
| 483 | + | assert.Equal(t, "/", rec.Header().Get("Location")) | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | // The header links: "Sign in" for a visitor, "Passkeys" for the owner. | |
| 487 | + | func TestPasskeyHeaderLinks(t *testing.T) { | |
| 488 | + | t.Parallel() | |
| 489 | + | f := newPasskeyServer(t) | |
| 490 | + | rec := f.do(t, req{method: "GET", target: "/-/login"}) | |
| 491 | + | assert.Contains(t, rec.Body.String(), `href="/-/login"`) | |
| 492 | + | assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`) | |
| 493 | + | assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded") | |
| 494 | + | ||
| 495 | + | f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 496 | + | rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) | |
| 497 | + | assert.Contains(t, rec.Body.String(), `href="/-/passkeys"`) | |
| 498 | + | assert.Contains(t, rec.Body.String(), `action="/-/logout"`) | |
| 499 | + | assert.NotContains(t, rec.Body.String(), `>Sign in</a>`) | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | // Without the passkey service there is no route, no link and no script | |
| 503 | + | // behavior to reach: the routes answer like any unknown path. | |
| 504 | + | func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) { | |
| 505 | + | t.Parallel() | |
| 506 | + | lim := &blockingLimiter{} | |
| 507 | + | srv := newTestServer(t, Config{ | |
| 508 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |
| 509 | + | PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim, | |
| 510 | + | }) | |
| 511 | + | do := func(method, target, cookie string) *httptest.ResponseRecorder { | |
| 512 | + | r := httptest.NewRequest(method, target, strings.NewReader(finishBody)) | |
| 513 | + | r.Header.Set("Origin", forgeOrigin) | |
| 514 | + | r.Header.Set("Content-Type", "application/json") | |
| 515 | + | if cookie != "" { | |
| 516 | + | r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) | |
| 517 | + | } | |
| 518 | + | rec := httptest.NewRecorder() | |
| 519 | + | srv.ServeHTTP(rec, r) | |
| 520 | + | return rec | |
| 521 | + | } | |
| 522 | + | for _, target := range []string{"/-/login", "/-/passkeys"} { | |
| 523 | + | assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target) | |
| 524 | + | assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target) | |
| 525 | + | } | |
| 526 | + | // A POST to a path without a route gets what any unknown path gets for a | |
| 527 | + | // POST (the catch-all page route is GET only). | |
| 528 | + | unknown := do("POST", "/-/nothing-here", "tok").Code | |
| 529 | + | assert.Equal(t, http.StatusMethodNotAllowed, unknown) | |
| 530 | + | for _, target := range []string{beginLogin, finishLogin} { | |
| 531 | + | assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST") | |
| 532 | + | assert.Equal(t, unknown, do("POST", target, "tok").Code, target) | |
| 533 | + | } | |
| 534 | + | for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} { | |
| 535 | + | assert.Equal(t, unknown, do("POST", target, "tok").Code, target) | |
| 536 | + | } | |
| 537 | + | // And no link in the header. | |
| 538 | + | rec := do("GET", "/", "") | |
| 539 | + | assert.NotContains(t, rec.Body.String(), `href="/-/login"`) | |
| 540 | + | rec = do("GET", "/", "tok") | |
| 541 | + | assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`) | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | // Passkeys need sessions: with the service alone nothing is served. | |
| 545 | + | func TestPasskeyRoutesNeedSessions(t *testing.T) { | |
| 546 | + | t.Parallel() | |
| 547 | + | srv := newTestServer(t, Config{ | |
| 548 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |
| 549 | + | PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t), | |
| 550 | + | }) | |
| 551 | + | rec := httptest.NewRecorder() | |
| 552 | + | srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil)) | |
| 553 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 554 | + | r := httptest.NewRequest("POST", beginLogin, nil) | |
| 555 | + | r.Header.Set("Origin", forgeOrigin) | |
| 556 | + | rec = httptest.NewRecorder() | |
| 557 | + | srv.ServeHTTP(rec, r) | |
| 558 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | // The existing login links keep working next to the passkey routes. | |
| 562 | + | func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) { | |
| 563 | + | t.Parallel() | |
| 564 | + | f := newPasskeyServer(t) | |
| 565 | + | rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA}) | |
| 566 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 567 | + | assert.Contains(t, rec.Body.String(), "sign in as the owner") | |
| 568 | + | rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin}) | |
| 569 | + | assert.Equal(t, http.StatusSeeOther, rec.Code) | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | func TestIsPasskeyLoginPath(t *testing.T) { | |
| 573 | + | t.Parallel() | |
| 574 | + | assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin")) | |
| 575 | + | assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish")) | |
| 576 | + | for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} { | |
| 577 | + | assert.False(t, isPasskeyLoginPath(p), p) | |
| 578 | + | } | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | // A finish that cannot be read is a 4xx, and is not counted as a failed | |
| 582 | + | // sign-in: the client is buggy, it did not guess at a passkey. | |
| 583 | + | func TestMalformedFinishIsNotAFailedSignIn(t *testing.T) { | |
| 584 | + | t.Parallel() | |
| 585 | + | f := newPasskeyServer(t) | |
| 586 | + | for _, r := range []req{ | |
| 587 | + | {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"}, | |
| 588 | + | postJSON(finishLogin, "", `not json`), | |
| 589 | + | postJSON(finishLogin, "", `{}`), | |
| 590 | + | postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)), | |
| 591 | + | } { | |
| 592 | + | rec := f.do(t, r) | |
| 593 | + | assert.GreaterOrEqual(t, rec.Code, 400) | |
| 594 | + | assert.Less(t, rec.Code, 500) | |
| 595 | + | } | |
| 596 | + | assert.Zero(t, f.lim.fails) | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // The client key the web hands to the use case is the one the failure limiter | |
| 600 | + | // counts under: the address with trusted proxies applied, an IPv4 address as it | |
| 601 | + | // is (mapped or not), an IPv6 one cut to its /64. | |
| 602 | + | func TestPasskeyLoginBeginPassesTheClientKey(t *testing.T) { | |
| 603 | + | t.Parallel() | |
| 604 | + | for name, tc := range map[string]struct{ remote, xff, want string }{ | |
| 605 | + | "ipv4": {"192.0.2.77:51000", "", "192.0.2.77"}, | |
| 606 | + | "ipv4 mapped": {"[::ffff:192.0.2.77]:51000", "", "192.0.2.77"}, | |
| 607 | + | "ipv6 host": {"[2001:db8:1:2::1]:51000", "", "2001:db8:1:2::/64"}, | |
| 608 | + | "ipv6 same /64": {"[2001:db8:1:2:ffff:ffff:ffff:ffff]:51000", "", "2001:db8:1:2::/64"}, | |
| 609 | + | "ipv6 another /64": {"[2001:db8:1:3::1]:51000", "", "2001:db8:1:3::/64"}, | |
| 610 | + | "forwarded for ignored": {"192.0.2.77:51000", "203.0.113.5", "192.0.2.77"}, | |
| 611 | + | } { | |
| 612 | + | t.Run(name, func(t *testing.T) { | |
| 613 | + | t.Parallel() | |
| 614 | + | f := newPasskeyServer(t) | |
| 615 | + | f.passkeys.EXPECT().BeginLogin(mock.Anything, tc.want).Return("c", []byte(`{}`), nil).Once() | |
| 616 | + | r := httptest.NewRequest("POST", beginLogin, nil) | |
| 617 | + | r.RemoteAddr = tc.remote | |
| 618 | + | r.Header.Set("Origin", forgeOrigin) | |
| 619 | + | if tc.xff != "" { | |
| 620 | + | r.Header.Set("X-Forwarded-For", tc.xff) // no trusted proxy: ignored | |
| 621 | + | } | |
| 622 | + | rec := httptest.NewRecorder() | |
| 623 | + | f.srv.ServeHTTP(rec, r) | |
| 624 | + | assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| 625 | + | }) | |
| 626 | + | } | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | // The name travels with register/begin and is checked there. | |
| 630 | + | func TestPasskeyRegisterBeginChecksTheName(t *testing.T) { | |
| 631 | + | t.Parallel() | |
| 632 | + | f := newPasskeyServer(t) | |
| 633 | + | f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "Pixel 9").Return("cer", []byte(`{"publicKey":{}}`), nil).Once() | |
| 634 | + | rec := f.do(t, postJSON(beginRegister, "tok", `{"name":"Pixel 9"}`)) | |
| 635 | + | assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| 636 | + | ||
| 637 | + | f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "bad\u0000name"). | |
| 638 | + | Return("", nil, &domain.TextError{Reason: "contains the control character U+0000"}).Once() | |
| 639 | + | rec = f.do(t, postJSON(beginRegister, "tok", `{"name":"bad\u0000name"}`)) | |
| 640 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 641 | + | assert.Contains(t, rec.Body.String(), "control character") | |
| 642 | + | ||
| 643 | + | // No body at all is no name; a body that is not JSON, or not the right type, is refused. | |
| 644 | + | f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer", []byte(`{}`), nil).Once() | |
| 645 | + | assert.Equal(t, http.StatusOK, f.do(t, postJSON(beginRegister, "tok", ``)).Code) | |
| 646 | + | assert.Equal(t, http.StatusBadRequest, f.do(t, postJSON(beginRegister, "tok", `nope`)).Code) | |
| 647 | + | assert.Equal(t, http.StatusUnsupportedMediaType, f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}).Code) | |
| 648 | + | } | |
M internal/adapters/web/session.go +12 -1
| @@ -180,13 +180,24 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool { | |||
| 180 | 180 | denyForbidden(w) | |
| 181 | 181 | return false | |
| 182 | 182 | } | |
| 183 | - | if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) { | |
| 183 | + | if !SignedIn(r.Context()) && !s.anonymousPost(r) { | |
| 184 | 184 | denyForbidden(w) | |
| 185 | 185 | return false | |
| 186 | 186 | } | |
| 187 | 187 | return true | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | + | // anonymousPost tells whether a POST without a session may pass the gate: the | |
| 191 | + | // requests that start a session. They are the confirmation of a login link and, | |
| 192 | + | // when passkeys are on, the two endpoints of a passkey sign-in; each is matched | |
| 193 | + | // exactly. | |
| 194 | + | func (s *Server) anonymousPost(r *http.Request) bool { | |
| 195 | + | if r.Method != http.MethodPost { | |
| 196 | + | return false | |
| 197 | + | } | |
| 198 | + | return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) | |
| 199 | + | } | |
| 200 | + | ||
| 190 | 201 | // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of | |
| 191 | 202 | // the right shape and nothing after it. It looks at the escaped path, which is | |
| 192 | 203 | // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass | |
M internal/adapters/web/shell.templ +6 -0
| @@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) { | |||
| 22 | 22 | <script src={ l.Assets.SSE } defer></script> | |
| 23 | 23 | <script src={ l.Assets.App } defer></script> | |
| 24 | 24 | <script src={ l.Assets.Files } defer></script> | |
| 25 | + | <script src={ l.Assets.Passkey } defer></script> | |
| 25 | 26 | <script src={ l.Assets.Log } defer></script> | |
| 26 | 27 | </head> | |
| 27 | 28 | <body | |
| @@ -124,8 +125,13 @@ templ Header(l Layout) { | |||
| 124 | 125 | if SignedIn(ctx) { | |
| 125 | 126 | <form class="owner" method="post" action="/-/logout"> | |
| 126 | 127 | <span class="owner-badge" title="signed in as the owner">owner</span> | |
| 128 | + | if l.Passkeys { | |
| 129 | + | <a class="btn" href="/-/passkeys" title="add or remove the passkeys that sign you in">Passkeys</a> | |
| 130 | + | } | |
| 127 | 131 | <button type="submit" class="btn" title="sign out of this browser">Sign out</button> | |
| 128 | 132 | </form> | |
| 133 | + | } else if l.Passkeys { | |
| 134 | + | <a class="btn" href="/-/login" title="sign in with a passkey">Sign in</a> | |
| 129 | 135 | } | |
| 130 | 136 | </div> | |
| 131 | 137 | </div> | |
M internal/adapters/web/shell_templ.go +172 -144 generated file: collapsed
M internal/adapters/web/static/css/30-list.css +15 -0
| @@ -47,6 +47,21 @@ | |||
| 47 | 47 | & .c-sub { width: auto; } | |
| 48 | 48 | & .c-who { width: 130px; } | |
| 49 | 49 | } | |
| 50 | + | .list.passkeys { | |
| 51 | + | & .c-name { width: auto; font-weight: 600; } | |
| 52 | + | & .c-when { width: 120px; font-size: var(--fs-s); } | |
| 53 | + | & .c-sync { width: 80px; font-size: var(--fs-s); } | |
| 54 | + | & .c-act { width: 110px; text-align: right; } | |
| 55 | + | } | |
| 56 | + | .passkey-add { | |
| 57 | + | display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; margin-top: var(--s4); | |
| 58 | + | & input { | |
| 59 | + | flex: 1; min-width: 12ch; max-width: 320px; height: 32px; padding: 0 var(--s3); | |
| 60 | + | background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s); | |
| 61 | + | } | |
| 62 | + | & input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; } | |
| 63 | + | & input::placeholder { color: var(--ink-mute); opacity: 1; } | |
| 64 | + | } | |
| 50 | 65 | .list.commits, .list.refs { | |
| 51 | 66 | & .c-sha { width: 96px; } | |
| 52 | 67 | & .c-who { width: 140px; } | |
A internal/adapters/web/static/js/passkey.js +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys). | |
| 2 | + | // | |
| 3 | + | // One delegated submit handler for both pages: the sign-in button and the "add | |
| 4 | + | // a passkey" button are the submit buttons of a [data-passkey-form], so that a | |
| 5 | + | // click, Enter in the name field and the keyboard all end in the same place. | |
| 6 | + | // The button carries the two endpoints (data-begin, data-finish). While a | |
| 7 | + | // ceremony runs the button is disabled, and a disabled submit button makes the | |
| 8 | + | // form submit nothing: that is the only guard against a second prompt. The flow | |
| 9 | + | // is the one of the WebAuthn JSON API: POST begin, hand options.publicKey to the | |
| 10 | + | // browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST | |
| 11 | + | // the result (toJSON()) to finish. Nothing is inline: the CSP only allows | |
| 12 | + | // scripts from this origin. The core is tested in Node by | |
| 13 | + | // jstest/passkey.test.mjs. | |
| 14 | + | window.ermitePasskey = (() => { | |
| 15 | + | const UNSUPPORTED = 'this browser does not support passkeys'; | |
| 16 | + | const UNEXPECTED = 'the server sent an unexpected answer'; | |
| 17 | + | const SESSION_ENDED = 'your session ended or this page is out of date; reload and sign in again'; | |
| 18 | + | const WAITING = 'waiting for your passkey…'; | |
| 19 | + | const MAX_NAME_BYTES = 64; | |
| 20 | + | ||
| 21 | + | // The parse function each ceremony needs; absent in a browser that predates | |
| 22 | + | // the WebAuthn JSON API. | |
| 23 | + | const parser = { login: 'parseRequestOptionsFromJSON', register: 'parseCreationOptionsFromJSON' }; | |
| 24 | + | const supported = (kind) => { | |
| 25 | + | const pkc = window.PublicKeyCredential; | |
| 26 | + | return !!pkc && typeof pkc[parser[kind]] === 'function' && !!navigator.credentials; | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | const statusEl = () => document.querySelector('[data-passkey-status]'); | |
| 30 | + | const say = (text) => { const s = statusEl(); if (s) s.textContent = text; }; | |
| 31 | + | ||
| 32 | + | // nameProblem says why a passkey name would be refused, or '' when it is fine. | |
| 33 | + | // It mirrors the server (domain.ParsePasskeyName): at most 64 bytes of UTF-8, | |
| 34 | + | // one line, no control character and no bidirectional mark, override or isolate. | |
| 35 | + | // The server stays the judge; this only saves the owner a prompt that would end | |
| 36 | + | // in a refusal. | |
| 37 | + | function nameProblem(name) { | |
| 38 | + | if (new TextEncoder().encode(name).length > MAX_NAME_BYTES) { | |
| 39 | + | return 'the name is longer than ' + MAX_NAME_BYTES + ' bytes'; | |
| 40 | + | } | |
| 41 | + | // C0 and C1 controls (this includes tab and line breaks), the line and | |
| 42 | + | // paragraph separators, and the bidirectional marks (U+200E, U+200F, U+061C, | |
| 43 | + | // U+202A to U+202E, U+2066 to U+2069). | |
| 44 | + | if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/.test(name)) { | |
| 45 | + | return 'the name has a control or direction character'; | |
| 46 | + | } | |
| 47 | + | return ''; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // messageOf turns what a step threw into one line for the owner. | |
| 51 | + | function messageOf(e) { | |
| 52 | + | if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again'; | |
| 53 | + | if (e && e.name === 'InvalidStateError') return 'this authenticator already holds a passkey for this site'; | |
| 54 | + | if (e && e.name === 'AbortError') return 'cancelled'; | |
| 55 | + | return (e && e.message) || 'something went wrong; try again'; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // failure reads the error of a refused request: the server's one-line | |
| 59 | + | // reason when it sent one, the status otherwise. A 403 without a reason of | |
| 60 | + | // its own is a session that ended, or a page older than the server's state. | |
| 61 | + | async function failure(res) { | |
| 62 | + | let reason = ''; | |
| 63 | + | try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ } | |
| 64 | + | const e = new Error(reason || 'the server answered ' + res.status); | |
| 65 | + | if (res.status === 403 && (!reason || reason === 'forbidden')) e.message = SESSION_ENDED; | |
| 66 | + | e.status = res.status; | |
| 67 | + | return e; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // begin posts to the begin endpoint and checks the shape of the answer. | |
| 71 | + | async function begin(btn, kind, name) { | |
| 72 | + | const init = { method: 'POST', credentials: 'same-origin' }; | |
| 73 | + | if (kind === 'register') { | |
| 74 | + | init.headers = { 'Content-Type': 'application/json' }; | |
| 75 | + | init.body = JSON.stringify({ name }); | |
| 76 | + | } | |
| 77 | + | const res = await fetch(btn.dataset.begin, init); | |
| 78 | + | if (!res.ok) { | |
| 79 | + | const e = await failure(res); | |
| 80 | + | e.atBegin = true; // a 400 here is the server refusing the name | |
| 81 | + | throw e; | |
| 82 | + | } | |
| 83 | + | let answer; | |
| 84 | + | try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); } | |
| 85 | + | const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' && | |
| 86 | + | answer.options && typeof answer.options === 'object' && | |
| 87 | + | answer.options.publicKey && typeof answer.options.publicKey === 'object'; | |
| 88 | + | if (!ok) throw new Error(UNEXPECTED); | |
| 89 | + | return answer; | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | async function run(btn) { | |
| 93 | + | const kind = btn.dataset.passkey; | |
| 94 | + | if (!parser[kind]) return; | |
| 95 | + | if (!supported(kind)) { say(UNSUPPORTED); return; } | |
| 96 | + | ||
| 97 | + | const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null; | |
| 98 | + | const name = nameEl ? nameEl.value.trim() : ''; | |
| 99 | + | if (nameEl) { | |
| 100 | + | const problem = nameProblem(name); | |
| 101 | + | if (problem) { say(problem); nameEl.focus(); return; } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | btn.disabled = true; | |
| 105 | + | btn.setAttribute('aria-busy', 'true'); | |
| 106 | + | say(WAITING); | |
| 107 | + | let done = false; | |
| 108 | + | let failed = null; | |
| 109 | + | try { | |
| 110 | + | const { ceremony, options } = await begin(btn, kind, name); | |
| 111 | + | const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey); | |
| 112 | + | const cred = kind === 'login' | |
| 113 | + | ? await navigator.credentials.get({ publicKey }) | |
| 114 | + | : await navigator.credentials.create({ publicKey }); | |
| 115 | + | if (!cred) throw new Error('no passkey was returned'); | |
| 116 | + | if (typeof cred.toJSON !== 'function') throw new Error('this browser returned a passkey this page cannot send; update the browser'); | |
| 117 | + | const finished = await fetch(btn.dataset.finish, { | |
| 118 | + | method: 'POST', | |
| 119 | + | credentials: 'same-origin', | |
| 120 | + | headers: { 'Content-Type': 'application/json' }, | |
| 121 | + | body: JSON.stringify({ ceremony, credential: cred.toJSON() }), | |
| 122 | + | }); | |
| 123 | + | if (!finished.ok) throw await failure(finished); | |
| 124 | + | done = true; | |
| 125 | + | if (kind === 'login') location.assign('/'); | |
| 126 | + | else location.reload(); | |
| 127 | + | } catch (e) { | |
| 128 | + | say(messageOf(e)); | |
| 129 | + | failed = e; | |
| 130 | + | } finally { | |
| 131 | + | if (!done) { | |
| 132 | + | btn.removeAttribute('aria-busy'); | |
| 133 | + | btn.disabled = false; | |
| 134 | + | // A disabled button cannot hold the focus. When the server refused the | |
| 135 | + | // name the field is what to fix; otherwise the button, to try again. | |
| 136 | + | if (nameEl && failed && failed.atBegin && failed.status === 400) nameEl.focus(); | |
| 137 | + | else btn.focus(); | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // submit handles both forms. The event comes from the form itself. | |
| 143 | + | function submit(ev) { | |
| 144 | + | const form = ev.target; | |
| 145 | + | if (!form || typeof form.matches !== 'function' || !form.matches('[data-passkey-form]')) return; | |
| 146 | + | if (ev.preventDefault) ev.preventDefault(); // never a real navigation | |
| 147 | + | const btn = form.querySelector('[data-passkey]'); | |
| 148 | + | if (!btn || btn.disabled) return; | |
| 149 | + | return run(btn); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // init disables the buttons of a browser that cannot do passkeys, and says why. | |
| 153 | + | function init() { | |
| 154 | + | let missing = false; | |
| 155 | + | document.querySelectorAll('[data-passkey]').forEach((btn) => { | |
| 156 | + | if (parser[btn.dataset.passkey] && !supported(btn.dataset.passkey)) { | |
| 157 | + | btn.disabled = true; | |
| 158 | + | missing = true; | |
| 159 | + | } | |
| 160 | + | }); | |
| 161 | + | if (missing) say(UNSUPPORTED); | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | if (typeof document.addEventListener === 'function') { | |
| 165 | + | document.addEventListener('submit', submit); | |
| 166 | + | init(); | |
| 167 | + | } | |
| 168 | + | return { submit, init, nameProblem }; | |
| 169 | + | })(); | |
M internal/adapters/web/view.go +33 -0
| @@ -60,6 +60,9 @@ type Layout struct { | |||
| 60 | 60 | // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with; | |
| 61 | 61 | // both are "" when the page has no commit to search. | |
| 62 | 62 | PathsURL, BlobBase string | |
| 63 | + | // Passkeys tells that the passkey routes exist: the header then links to | |
| 64 | + | // them. | |
| 65 | + | Passkeys bool | |
| 63 | 66 | } | |
| 64 | 67 | ||
| 65 | 68 | // Link is a plain label + href pair. | |
| @@ -195,3 +198,33 @@ type LoginPage struct { | |||
| 195 | 198 | Action string // where the form posts | |
| 196 | 199 | Button string | |
| 197 | 200 | } | |
| 201 | + | ||
| 202 | + | // PasskeyLoginPage is the sign-in page: one button the script drives. | |
| 203 | + | type PasskeyLoginPage struct { | |
| 204 | + | Title string | |
| 205 | + | Lines []string | |
| 206 | + | Button string | |
| 207 | + | // Begin and Finish are the endpoints the script posts to. | |
| 208 | + | Begin, Finish string | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // PasskeyRow is one passkey on the passkeys page. | |
| 212 | + | type PasskeyRow struct { | |
| 213 | + | Name string | |
| 214 | + | // Created and LastUsed are relative ("3d ago"); the At fields are the | |
| 215 | + | // RFC 3339 forms, "" when never. | |
| 216 | + | Created, CreatedAt string | |
| 217 | + | LastUsed, LastUsedAt string | |
| 218 | + | Synced bool | |
| 219 | + | // Remove is where the remove form posts. | |
| 220 | + | Remove string | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // PasskeysPage lists the passkeys and adds one. | |
| 224 | + | type PasskeysPage struct { | |
| 225 | + | Rows []PasskeyRow | |
| 226 | + | Begin, Finish string | |
| 227 | + | // Full tells that no more passkeys can be added. | |
| 228 | + | Full bool | |
| 229 | + | Max int | |
| 230 | + | } | |
M internal/adapters/web/web.go +9 -0
| @@ -77,6 +77,11 @@ type Config struct { | |||
| 77 | 77 | // Sessions signs the owner in (ADR 0018). Without it there are no login | |
| 78 | 78 | // routes and every visitor is anonymous. | |
| 79 | 79 | Sessions ports.WebSessionService | |
| 80 | + | // Passkeys signs the owner in with a passkey and manages the passkeys | |
| 81 | + | // (ADR 0026). Without it, or without Sessions, there are no passkey | |
| 82 | + | // routes and no link to them. Leave it unset rather than assigning a typed | |
| 83 | + | // nil pointer. | |
| 84 | + | Passkeys ports.WebPasskeyService | |
| 80 | 85 | // Limiter counts the failed logins; required with Sessions. Share it with | |
| 81 | 86 | // the API and Git, so that a client has one budget of failures. | |
| 82 | 87 | Limiter ports.FailureLimiter | |
| @@ -139,6 +144,9 @@ func (s *Server) routes() *http.ServeMux { | |||
| 139 | 144 | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | |
| 140 | 145 | mux.HandleFunc("POST /-/logout", s.logout) | |
| 141 | 146 | } | |
| 147 | + | if s.passkeysOn() { | |
| 148 | + | s.passkeyRoutes(mux) | |
| 149 | + | } | |
| 142 | 150 | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 143 | 151 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 144 | 152 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| @@ -306,6 +314,7 @@ func (s *Server) base() Layout { | |||
| 306 | 314 | Version: s.cfg.Version, | |
| 307 | 315 | SourceURL: s.cfg.SourceURL, | |
| 308 | 316 | Assets: s.assets.urls, | |
| 317 | + | Passkeys: s.passkeysOn(), | |
| 309 | 318 | } | |
| 310 | 319 | } | |
| 311 | 320 | ||
A internal/adapters/webauthn/webauthn.go +207 -0
| @@ -0,0 +1,207 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package webauthn implements ports.PasskeyVerifier over go-webauthn (ADR | |
| 4 | + | // 0026). It is the only importer of the library: the use case sees plain | |
| 5 | + | // domain values and opaque ceremony bytes. | |
| 6 | + | package webauthn | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "encoding/json" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "net/url" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "github.com/go-webauthn/webauthn/protocol" | |
| 16 | + | gowebauthn "github.com/go-webauthn/webauthn/webauthn" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // ownerName names the one user in the options sent to the browser. | |
| 23 | + | const ownerName = "owner" | |
| 24 | + | ||
| 25 | + | // Verifier runs the WebAuthn ceremonies for the owner of one public URL. | |
| 26 | + | type Verifier struct { | |
| 27 | + | web *gowebauthn.WebAuthn | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | var _ ports.PasskeyVerifier = (*Verifier)(nil) | |
| 31 | + | ||
| 32 | + | // New builds a verifier for publicURL: the relying party ID is its host name, | |
| 33 | + | // the only accepted origin is its origin. It fails when the library refuses the | |
| 34 | + | // configuration, for example for a host that is an IP address. | |
| 35 | + | func New(publicURL string) (*Verifier, error) { | |
| 36 | + | u, err := url.Parse(publicURL) | |
| 37 | + | if err != nil { | |
| 38 | + | return nil, fmt.Errorf("passkeys: parse public URL: %w", err) | |
| 39 | + | } | |
| 40 | + | host := strings.ToLower(u.Hostname()) | |
| 41 | + | if (u.Scheme != "http" && u.Scheme != "https") || host == "" { | |
| 42 | + | return nil, fmt.Errorf("passkeys: public URL %q needs an http(s) scheme and a host", publicURL) | |
| 43 | + | } | |
| 44 | + | timeout := gowebauthn.TimeoutConfig{Enforce: true, Timeout: domain.PasskeyCeremonyTTL, TimeoutUVD: domain.PasskeyCeremonyTTL} | |
| 45 | + | web, err := gowebauthn.New(&gowebauthn.Config{ | |
| 46 | + | RPID: host, | |
| 47 | + | RPDisplayName: host, | |
| 48 | + | RPOrigins: []string{u.Scheme + "://" + strings.ToLower(u.Host)}, | |
| 49 | + | AuthenticatorSelection: protocol.AuthenticatorSelection{ | |
| 50 | + | ResidentKey: protocol.ResidentKeyRequirementRequired, | |
| 51 | + | RequireResidentKey: protocol.ResidentKeyRequired(), | |
| 52 | + | UserVerification: protocol.VerificationRequired, | |
| 53 | + | }, | |
| 54 | + | AttestationPreference: protocol.PreferNoAttestation, | |
| 55 | + | Timeouts: gowebauthn.TimeoutsConfig{Login: timeout, Registration: timeout}, | |
| 56 | + | }) | |
| 57 | + | if err != nil { | |
| 58 | + | return nil, fmt.Errorf("passkeys: %w", err) | |
| 59 | + | } | |
| 60 | + | return &Verifier{web: web}, nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // owner is the single WebAuthn user. | |
| 64 | + | type owner struct { | |
| 65 | + | handle []byte | |
| 66 | + | credentials []gowebauthn.Credential | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (o owner) WebAuthnID() []byte { return o.handle } | |
| 70 | + | func (o owner) WebAuthnName() string { return ownerName } | |
| 71 | + | func (o owner) WebAuthnDisplayName() string { return ownerName } | |
| 72 | + | func (o owner) WebAuthnCredentials() []gowebauthn.Credential { return o.credentials } | |
| 73 | + | ||
| 74 | + | // refuse builds the error of a failed verification: an ErrUnauthenticated whose | |
| 75 | + | // text carries the reason, for logs. The library error is flattened to text so | |
| 76 | + | // that its types never leave this package. | |
| 77 | + | func refuse(what string, err error) error { | |
| 78 | + | return fmt.Errorf("%w: passkey %s: %v", domain.ErrUnauthenticated, what, err) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // BeginRegistration implements ports.PasskeyVerifier. | |
| 82 | + | func (v *Verifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) { | |
| 83 | + | exclude := make([]protocol.CredentialDescriptor, 0, len(existing)) | |
| 84 | + | for _, p := range existing { | |
| 85 | + | exclude = append(exclude, descriptor(p)) | |
| 86 | + | } | |
| 87 | + | creation, session, err := v.web.BeginRegistration(owner{handle: userHandle}, | |
| 88 | + | gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), | |
| 89 | + | gowebauthn.WithExclusions(exclude), | |
| 90 | + | gowebauthn.WithConveyancePreference(protocol.PreferNoAttestation), | |
| 91 | + | ) | |
| 92 | + | if err != nil { | |
| 93 | + | return nil, nil, fmt.Errorf("begin passkey registration: %w", err) | |
| 94 | + | } | |
| 95 | + | return encode(creation, session) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // FinishRegistration implements ports.PasskeyVerifier. | |
| 99 | + | func (v *Verifier) FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error) { | |
| 100 | + | var session gowebauthn.SessionData | |
| 101 | + | if err := json.Unmarshal(ceremony, &session); err != nil { | |
| 102 | + | return domain.Passkey{}, refuse("registration", fmt.Errorf("decode ceremony: %w", err)) | |
| 103 | + | } | |
| 104 | + | parsed, err := protocol.ParseCredentialCreationResponseBytes(response) | |
| 105 | + | if err != nil { | |
| 106 | + | return domain.Passkey{}, refuse("registration", err) | |
| 107 | + | } | |
| 108 | + | cred, err := v.web.CreateCredential(owner{handle: userHandle}, session, parsed) | |
| 109 | + | if err != nil { | |
| 110 | + | return domain.Passkey{}, refuse("registration", err) | |
| 111 | + | } | |
| 112 | + | transports := make([]string, 0, len(cred.Transport)) | |
| 113 | + | for _, t := range cred.Transport { | |
| 114 | + | transports = append(transports, string(t)) | |
| 115 | + | } | |
| 116 | + | return domain.Passkey{ | |
| 117 | + | ID: cred.ID, | |
| 118 | + | PublicKey: cred.PublicKey, | |
| 119 | + | AttestationType: cred.AttestationType, | |
| 120 | + | Transports: transports, | |
| 121 | + | SignCount: cred.Authenticator.SignCount, | |
| 122 | + | BackupEligible: cred.Flags.BackupEligible, | |
| 123 | + | BackupState: cred.Flags.BackupState, | |
| 124 | + | }, nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // BeginLogin implements ports.PasskeyVerifier. | |
| 128 | + | func (v *Verifier) BeginLogin() ([]byte, []byte, error) { | |
| 129 | + | assertion, session, err := v.web.BeginDiscoverableLogin() | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, nil, fmt.Errorf("begin passkey login: %w", err) | |
| 132 | + | } | |
| 133 | + | return encode(assertion, session) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // FinishLogin implements ports.PasskeyVerifier. The library compares the user | |
| 137 | + | // handle of the response with userHandle. | |
| 138 | + | // | |
| 139 | + | //hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; it decides nothing: the use case refuses a counter that does not advance | |
| 140 | + | func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 141 | + | var session gowebauthn.SessionData | |
| 142 | + | if err := json.Unmarshal(ceremony, &session); err != nil { | |
| 143 | + | return domain.Passkey{}, refuse("login", fmt.Errorf("decode ceremony: %w", err)) | |
| 144 | + | } | |
| 145 | + | parsed, err := protocol.ParseCredentialRequestResponseBytes(response) | |
| 146 | + | if err != nil { | |
| 147 | + | return domain.Passkey{}, refuse("login", err) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | var stored domain.Passkey | |
| 151 | + | var lookupErr error // a failure of the store, as opposed to an unknown credential | |
| 152 | + | handler := func(rawID, _ []byte) (gowebauthn.User, error) { | |
| 153 | + | p, err := lookup(rawID) | |
| 154 | + | if err != nil { | |
| 155 | + | if !errors.Is(err, domain.ErrNotFound) { | |
| 156 | + | lookupErr = err | |
| 157 | + | } | |
| 158 | + | return nil, err | |
| 159 | + | } | |
| 160 | + | stored = p | |
| 161 | + | return owner{handle: userHandle, credentials: []gowebauthn.Credential{credential(p)}}, nil | |
| 162 | + | } | |
| 163 | + | _, cred, err := v.web.ValidatePasskeyLogin(handler, session, parsed) | |
| 164 | + | if lookupErr != nil { | |
| 165 | + | return domain.Passkey{}, lookupErr // fail closed, as it is | |
| 166 | + | } | |
| 167 | + | if err != nil { | |
| 168 | + | return domain.Passkey{}, refuse("login", err) | |
| 169 | + | } | |
| 170 | + | // The library keeps the stored counter when the received one does not | |
| 171 | + | // advance (its clone warning); the use case refuses that, under its lock. | |
| 172 | + | stored.SignCount = cred.Authenticator.SignCount | |
| 173 | + | stored.BackupState = cred.Flags.BackupState | |
| 174 | + | return stored, nil | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // encode returns the JSON of the options for the browser and of the session. | |
| 178 | + | func encode(options any, session *gowebauthn.SessionData) (opts, ceremony []byte, err error) { | |
| 179 | + | if opts, err = json.Marshal(options); err != nil { | |
| 180 | + | return nil, nil, fmt.Errorf("encode passkey options: %w", err) | |
| 181 | + | } | |
| 182 | + | if ceremony, err = json.Marshal(session); err != nil { | |
| 183 | + | return nil, nil, fmt.Errorf("encode passkey ceremony: %w", err) | |
| 184 | + | } | |
| 185 | + | return opts, ceremony, nil | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // credential converts a stored passkey into the library's form. | |
| 189 | + | func credential(p domain.Passkey) gowebauthn.Credential { | |
| 190 | + | c := gowebauthn.Credential{ | |
| 191 | + | ID: p.ID, | |
| 192 | + | PublicKey: p.PublicKey, | |
| 193 | + | AttestationType: p.AttestationType, | |
| 194 | + | Flags: gowebauthn.CredentialFlags{BackupEligible: p.BackupEligible, BackupState: p.BackupState}, | |
| 195 | + | Authenticator: gowebauthn.Authenticator{SignCount: p.SignCount}, | |
| 196 | + | } | |
| 197 | + | for _, t := range p.Transports { | |
| 198 | + | c.Transport = append(c.Transport, protocol.AuthenticatorTransport(t)) | |
| 199 | + | } | |
| 200 | + | return c | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // descriptor names a stored passkey for the exclusion list. | |
| 204 | + | func descriptor(p domain.Passkey) protocol.CredentialDescriptor { | |
| 205 | + | c := credential(p) | |
| 206 | + | return c.Descriptor() | |
| 207 | + | } | |
A internal/adapters/webauthn/webauthn_test.go +446 -0
| @@ -0,0 +1,446 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package webauthn_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "maps" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/descope/virtualwebauthn" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/adapters/webauthn" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var userHandle = []byte("0123456789abcdef0123456789abcdef") | |
| 21 | + | ||
| 22 | + | // site is one public URL and the relying party a browser would present for it. | |
| 23 | + | type site struct { | |
| 24 | + | url string | |
| 25 | + | rp virtualwebauthn.RelyingParty | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | var sites = map[string]site{ | |
| 29 | + | "localhost": {"http://localhost:7341", virtualwebauthn.RelyingParty{ID: "localhost", Name: "localhost", Origin: "http://localhost:7341"}}, | |
| 30 | + | "https": {"https://git.example.test", virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: "https://git.example.test"}}, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // device is a virtual authenticator with one credential and the passkey the | |
| 34 | + | // server stored for it. | |
| 35 | + | type device struct { | |
| 36 | + | auth virtualwebauthn.Authenticator | |
| 37 | + | cred virtualwebauthn.Credential | |
| 38 | + | store map[string]domain.Passkey // by credential id | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (d *device) lookup(id []byte) (domain.Passkey, error) { | |
| 42 | + | p, ok := d.store[string(id)] | |
| 43 | + | if !ok { | |
| 44 | + | return domain.Passkey{}, domain.ErrNotFound | |
| 45 | + | } | |
| 46 | + | return p, nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // register runs a registration ceremony against v with a new credential. | |
| 50 | + | func register(t *testing.T, v *webauthn.Verifier, s site, opts virtualwebauthn.AuthenticatorOptions) *device { | |
| 51 | + | t.Helper() | |
| 52 | + | opts.UserHandle = userHandle | |
| 53 | + | d := &device{ | |
| 54 | + | auth: virtualwebauthn.NewAuthenticatorWithOptions(opts), | |
| 55 | + | cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2), | |
| 56 | + | store: map[string]domain.Passkey{}, | |
| 57 | + | } | |
| 58 | + | options, ceremony, err := v.BeginRegistration(userHandle, nil) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | parsed, err := virtualwebauthn.ParseAttestationOptions(string(options)) | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | response := virtualwebauthn.CreateAttestationResponse(s.rp, d.auth, d.cred, *parsed) | |
| 63 | + | ||
| 64 | + | p, err := v.FinishRegistration(userHandle, ceremony, []byte(response)) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | assert.Equal(t, d.cred.ID, p.ID) | |
| 67 | + | assert.NotEmpty(t, p.PublicKey) | |
| 68 | + | p.Name = "test" | |
| 69 | + | d.store[string(p.ID)] = p | |
| 70 | + | d.auth.AddCredential(d.cred) | |
| 71 | + | return d | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // assertion starts a login and returns the ceremony and the signed response. | |
| 75 | + | func (d *device) assertion(t *testing.T, v *webauthn.Verifier, s site) (ceremony []byte, options *virtualwebauthn.AssertionOptions) { | |
| 76 | + | t.Helper() | |
| 77 | + | raw, ceremony, err := v.BeginLogin() | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | options, err = virtualwebauthn.ParseAssertionOptions(string(raw)) | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | return ceremony, options | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (d *device) respond(s site, options *virtualwebauthn.AssertionOptions) []byte { | |
| 85 | + | return []byte(virtualwebauthn.CreateAssertionResponse(s.rp, d.auth, d.cred, *options)) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestRegisterThenLogin(t *testing.T) { | |
| 89 | + | t.Parallel() | |
| 90 | + | for name, s := range sites { | |
| 91 | + | t.Run(name, func(t *testing.T) { | |
| 92 | + | t.Parallel() | |
| 93 | + | v, err := webauthn.New(s.url) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true}) | |
| 96 | + | d.cred.Counter = 5 | |
| 97 | + | ||
| 98 | + | ceremony, options := d.assertion(t, v, s) | |
| 99 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | assert.Equal(t, d.cred.ID, got.ID) | |
| 102 | + | assert.Equal(t, "test", got.Name, "the stored fields are kept") | |
| 103 | + | assert.Equal(t, uint32(5), got.SignCount, "the new sign count") | |
| 104 | + | assert.True(t, got.BackupEligible) | |
| 105 | + | assert.False(t, got.BackupState) | |
| 106 | + | assert.Equal(t, d.store[string(got.ID)].PublicKey, got.PublicKey) | |
| 107 | + | ||
| 108 | + | // The next login advances the counter again. | |
| 109 | + | d.store[string(got.ID)] = got | |
| 110 | + | d.cred.Counter = 6 | |
| 111 | + | ceremony, options = d.assertion(t, v, s) | |
| 112 | + | got, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | assert.Equal(t, uint32(6), got.SignCount) | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestRegistrationReportsBackupFlags(t *testing.T) { | |
| 120 | + | t.Parallel() | |
| 121 | + | s := sites["https"] | |
| 122 | + | v, err := webauthn.New(s.url) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true, | |
| 125 | + | Transports: []virtualwebauthn.Transport{virtualwebauthn.TransportInternal, virtualwebauthn.TransportHybrid}}) | |
| 126 | + | p := d.store[string(d.cred.ID)] | |
| 127 | + | assert.True(t, p.BackupEligible) | |
| 128 | + | assert.True(t, p.BackupState) | |
| 129 | + | assert.Equal(t, []string{"internal", "hybrid"}, p.Transports) | |
| 130 | + | ||
| 131 | + | // A login reports the backup state the authenticator has now. | |
| 132 | + | d.auth.Options.BackupState = false | |
| 133 | + | ceremony, options := d.assertion(t, v, s) | |
| 134 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | assert.False(t, got.BackupState) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestOptions(t *testing.T) { | |
| 140 | + | t.Parallel() | |
| 141 | + | s := sites["https"] | |
| 142 | + | v, err := webauthn.New(s.url) | |
| 143 | + | require.NoError(t, err) | |
| 144 | + | ||
| 145 | + | existing := domain.Passkey{ID: []byte("already-registered"), PublicKey: []byte("k"), Transports: []string{"usb"}} | |
| 146 | + | raw, _, err := v.BeginRegistration(userHandle, []domain.Passkey{existing}) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | var creation struct { | |
| 149 | + | PublicKey struct { | |
| 150 | + | RP struct{ ID, Name string } | |
| 151 | + | User struct{ ID, Name string } | |
| 152 | + | Challenge string | |
| 153 | + | AuthenticatorSelection struct { | |
| 154 | + | ResidentKey string | |
| 155 | + | RequireResidentKey bool | |
| 156 | + | UserVerification string | |
| 157 | + | } | |
| 158 | + | Attestation string | |
| 159 | + | Timeout int | |
| 160 | + | ExcludeCredentials []struct{ ID string } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | require.NoError(t, json.Unmarshal(raw, &creation)) | |
| 164 | + | pk := creation.PublicKey | |
| 165 | + | assert.Equal(t, "git.example.test", pk.RP.ID) | |
| 166 | + | assert.Equal(t, "owner", pk.User.Name) | |
| 167 | + | assert.NotEmpty(t, pk.Challenge) | |
| 168 | + | assert.Equal(t, "required", pk.AuthenticatorSelection.ResidentKey) | |
| 169 | + | assert.True(t, pk.AuthenticatorSelection.RequireResidentKey) | |
| 170 | + | assert.Equal(t, "required", pk.AuthenticatorSelection.UserVerification) | |
| 171 | + | assert.Equal(t, "none", pk.Attestation) | |
| 172 | + | assert.Equal(t, 120000, pk.Timeout) | |
| 173 | + | require.Len(t, pk.ExcludeCredentials, 1) | |
| 174 | + | parsed, err := virtualwebauthn.ParseAttestationOptions(string(raw)) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | require.Len(t, parsed.ExcludeCredentials, 1) | |
| 177 | + | ||
| 178 | + | raw, _, err = v.BeginLogin() | |
| 179 | + | require.NoError(t, err) | |
| 180 | + | var assertion struct { | |
| 181 | + | PublicKey struct { | |
| 182 | + | RPID string | |
| 183 | + | Challenge string | |
| 184 | + | UserVerification string | |
| 185 | + | Timeout int | |
| 186 | + | AllowCredentials []struct{ ID string } | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | require.NoError(t, json.Unmarshal(raw, &assertion)) | |
| 190 | + | assert.Equal(t, "git.example.test", assertion.PublicKey.RPID) | |
| 191 | + | assert.NotEmpty(t, assertion.PublicKey.Challenge) | |
| 192 | + | assert.Equal(t, "required", assertion.PublicKey.UserVerification, "login inherits user verification") | |
| 193 | + | assert.Equal(t, 120000, assertion.PublicKey.Timeout) | |
| 194 | + | assert.Empty(t, assertion.PublicKey.AllowCredentials, "a discoverable login lists no credential") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestLoginRefused(t *testing.T) { | |
| 198 | + | t.Parallel() | |
| 199 | + | s := sites["localhost"] | |
| 200 | + | v, err := webauthn.New(s.url) | |
| 201 | + | require.NoError(t, err) | |
| 202 | + | ||
| 203 | + | tests := []struct { | |
| 204 | + | name string | |
| 205 | + | // run returns the error of FinishLogin. | |
| 206 | + | run func(t *testing.T, d *device) error | |
| 207 | + | }{ | |
| 208 | + | {"wrong origin", func(t *testing.T, d *device) error { | |
| 209 | + | ceremony, options := d.assertion(t, v, s) | |
| 210 | + | other := s | |
| 211 | + | other.rp.Origin = "http://localhost:9999" | |
| 212 | + | return finish(v, ceremony, d.respond(other, options), d) | |
| 213 | + | }}, | |
| 214 | + | {"wrong RP ID", func(t *testing.T, d *device) error { | |
| 215 | + | ceremony, options := d.assertion(t, v, s) | |
| 216 | + | other := s | |
| 217 | + | other.rp.ID = "evil.example.test" | |
| 218 | + | return finish(v, ceremony, d.respond(other, options), d) | |
| 219 | + | }}, | |
| 220 | + | {"tampered challenge", func(t *testing.T, d *device) error { | |
| 221 | + | ceremony, options := d.assertion(t, v, s) | |
| 222 | + | options.Challenge[0] ^= 0xff | |
| 223 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 224 | + | }}, | |
| 225 | + | {"challenge of another ceremony", func(t *testing.T, d *device) error { | |
| 226 | + | ceremony, _ := d.assertion(t, v, s) | |
| 227 | + | _, other := d.assertion(t, v, s) | |
| 228 | + | return finish(v, ceremony, d.respond(s, other), d) | |
| 229 | + | }}, | |
| 230 | + | {"expired ceremony", func(t *testing.T, d *device) error { | |
| 231 | + | ceremony, options := d.assertion(t, v, s) | |
| 232 | + | return finish(v, setExpires(t, ceremony, time.Now().Add(-time.Second)), d.respond(s, options), d) | |
| 233 | + | }}, | |
| 234 | + | {"garbage ceremony", func(t *testing.T, d *device) error { | |
| 235 | + | _, options := d.assertion(t, v, s) | |
| 236 | + | return finish(v, []byte("not json"), d.respond(s, options), d) | |
| 237 | + | }}, | |
| 238 | + | {"garbage response", func(t *testing.T, d *device) error { | |
| 239 | + | ceremony, _ := d.assertion(t, v, s) | |
| 240 | + | return finish(v, ceremony, []byte("{}"), d) | |
| 241 | + | }}, | |
| 242 | + | {"user not verified", func(t *testing.T, d *device) error { | |
| 243 | + | d.auth.Options.UserNotVerified = true | |
| 244 | + | ceremony, options := d.assertion(t, v, s) | |
| 245 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 246 | + | }}, | |
| 247 | + | {"user not present", func(t *testing.T, d *device) error { | |
| 248 | + | d.auth.Options.UserNotPresent = true | |
| 249 | + | ceremony, options := d.assertion(t, v, s) | |
| 250 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 251 | + | }}, | |
| 252 | + | {"unknown credential", func(t *testing.T, d *device) error { | |
| 253 | + | ceremony, options := d.assertion(t, v, s) | |
| 254 | + | delete(d.store, string(d.cred.ID)) | |
| 255 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 256 | + | }}, | |
| 257 | + | {"other user handle", func(t *testing.T, d *device) error { | |
| 258 | + | d.auth.Options.UserHandle = []byte("a-handle-of-somebody-else-------") | |
| 259 | + | ceremony, options := d.assertion(t, v, s) | |
| 260 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 261 | + | }}, | |
| 262 | + | {"no user handle", func(t *testing.T, d *device) error { | |
| 263 | + | d.auth.Options.UserHandle = nil | |
| 264 | + | ceremony, options := d.assertion(t, v, s) | |
| 265 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 266 | + | }}, | |
| 267 | + | {"registration ceremony used for a login", func(t *testing.T, d *device) error { | |
| 268 | + | _, ceremony, err := v.BeginRegistration(userHandle, nil) | |
| 269 | + | require.NoError(t, err) | |
| 270 | + | _, options := d.assertion(t, v, s) | |
| 271 | + | return finish(v, ceremony, d.respond(s, options), d) | |
| 272 | + | }}, | |
| 273 | + | } | |
| 274 | + | for _, tc := range tests { | |
| 275 | + | t.Run(tc.name, func(t *testing.T) { | |
| 276 | + | t.Parallel() | |
| 277 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{}) | |
| 278 | + | opts := d.auth.Options | |
| 279 | + | stored := maps.Clone(d.store) | |
| 280 | + | err := tc.run(t, d) | |
| 281 | + | require.Error(t, err) | |
| 282 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 283 | + | ||
| 284 | + | // Control: the refusal was for the reason of the case. With the device | |
| 285 | + | // and the store as they were, a fresh assertion still succeeds, so the | |
| 286 | + | // case did not fail by something that breaks every login. | |
| 287 | + | d.auth.Options = opts | |
| 288 | + | d.store = stored | |
| 289 | + | ceremony, options := d.assertion(t, v, s) | |
| 290 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 291 | + | require.NoError(t, err, "a fresh, untouched assertion must succeed") | |
| 292 | + | assert.Equal(t, d.cred.ID, got.ID) | |
| 293 | + | }) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func finish(v *webauthn.Verifier, ceremony, response []byte, d *device) error { | |
| 298 | + | _, err := v.FinishLogin(userHandle, ceremony, response, d.lookup) | |
| 299 | + | return err | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // setExpires rewrites the expiry of a ceremony the way a late finish would see | |
| 303 | + | // it, since the library checks it against the wall clock. | |
| 304 | + | func setExpires(t *testing.T, ceremony []byte, at time.Time) []byte { | |
| 305 | + | t.Helper() | |
| 306 | + | var m map[string]any | |
| 307 | + | require.NoError(t, json.Unmarshal(ceremony, &m)) | |
| 308 | + | require.Contains(t, m, "expires") | |
| 309 | + | m["expires"] = at | |
| 310 | + | out, err := json.Marshal(m) | |
| 311 | + | require.NoError(t, err) | |
| 312 | + | return out | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func TestLoginStoreFailureIsNotAnUnauthenticated(t *testing.T) { | |
| 316 | + | t.Parallel() | |
| 317 | + | s := sites["localhost"] | |
| 318 | + | v, err := webauthn.New(s.url) | |
| 319 | + | require.NoError(t, err) | |
| 320 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{}) | |
| 321 | + | ceremony, options := d.assertion(t, v, s) | |
| 322 | + | boom := errors.New("disk on fire") | |
| 323 | + | _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), func([]byte) (domain.Passkey, error) { | |
| 324 | + | return domain.Passkey{}, boom | |
| 325 | + | }) | |
| 326 | + | require.ErrorIs(t, err, boom) | |
| 327 | + | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // A counter that does not advance is not refused here: the verifier reports the | |
| 331 | + | // stored counter (the library keeps it on a clone warning) and the use case, | |
| 332 | + | // which orders the logins under its lock, refuses it. TestPasskeyClonedAuthenticator | |
| 333 | + | // in package app checks the refusal end to end. | |
| 334 | + | func TestCounterThatDoesNotAdvanceKeepsTheStoredOne(t *testing.T) { | |
| 335 | + | t.Parallel() | |
| 336 | + | s := sites["localhost"] | |
| 337 | + | v, err := webauthn.New(s.url) | |
| 338 | + | require.NoError(t, err) | |
| 339 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{}) | |
| 340 | + | ||
| 341 | + | d.cred.Counter = 1 | |
| 342 | + | ceremony, options := d.assertion(t, v, s) | |
| 343 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 344 | + | require.NoError(t, err) | |
| 345 | + | assert.Equal(t, uint32(1), got.SignCount) | |
| 346 | + | d.store[string(got.ID)] = got | |
| 347 | + | ||
| 348 | + | // The counter did not advance: a copy of the key may be in use. The answer | |
| 349 | + | // is valid, and carries the stored counter, not a lower or equal new one. | |
| 350 | + | ceremony, options = d.assertion(t, v, s) | |
| 351 | + | again, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 352 | + | require.NoError(t, err) | |
| 353 | + | assert.Equal(t, uint32(1), again.SignCount, "the stored counter, unchanged") | |
| 354 | + | ||
| 355 | + | // A response with a lower counter keeps the stored one too. | |
| 356 | + | d.cred.Counter = 0 | |
| 357 | + | ceremony, options = d.assertion(t, v, s) | |
| 358 | + | lower, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | assert.Equal(t, uint32(1), lower.SignCount) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // Synced passkeys always report 0: that is not a clone. | |
| 364 | + | func TestZeroCounterIsNotAClone(t *testing.T) { | |
| 365 | + | t.Parallel() | |
| 366 | + | s := sites["localhost"] | |
| 367 | + | v, err := webauthn.New(s.url) | |
| 368 | + | require.NoError(t, err) | |
| 369 | + | d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true}) | |
| 370 | + | for range 2 { | |
| 371 | + | ceremony, options := d.assertion(t, v, s) | |
| 372 | + | got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup) | |
| 373 | + | require.NoError(t, err) | |
| 374 | + | assert.Zero(t, got.SignCount) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func TestRegistrationRefused(t *testing.T) { | |
| 379 | + | t.Parallel() | |
| 380 | + | s := sites["https"] | |
| 381 | + | v, err := webauthn.New(s.url) | |
| 382 | + | require.NoError(t, err) | |
| 383 | + | ||
| 384 | + | tests := []struct { | |
| 385 | + | name string | |
| 386 | + | auth virtualwebauthn.AuthenticatorOptions | |
| 387 | + | rp func(rp *virtualwebauthn.RelyingParty) | |
| 388 | + | // ceremony replaces the ceremony data when set. | |
| 389 | + | ceremony func(t *testing.T, ceremony []byte) []byte | |
| 390 | + | handle []byte | |
| 391 | + | }{ | |
| 392 | + | {name: "wrong origin", rp: func(rp *virtualwebauthn.RelyingParty) { rp.Origin = "https://evil.example.test" }}, | |
| 393 | + | {name: "wrong RP ID", rp: func(rp *virtualwebauthn.RelyingParty) { rp.ID = "evil.example.test" }}, | |
| 394 | + | {name: "user not verified", auth: virtualwebauthn.AuthenticatorOptions{UserNotVerified: true}}, | |
| 395 | + | {name: "expired ceremony", ceremony: func(t *testing.T, c []byte) []byte { return setExpires(t, c, time.Now().Add(-time.Second)) }}, | |
| 396 | + | {name: "other user", handle: []byte("somebody-else-------------------")}, | |
| 397 | + | } | |
| 398 | + | for _, tc := range tests { | |
| 399 | + | t.Run(tc.name, func(t *testing.T) { | |
| 400 | + | t.Parallel() | |
| 401 | + | rp := s.rp | |
| 402 | + | if tc.rp != nil { | |
| 403 | + | tc.rp(&rp) | |
| 404 | + | } | |
| 405 | + | auth := virtualwebauthn.NewAuthenticatorWithOptions(tc.auth) | |
| 406 | + | cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) | |
| 407 | + | options, ceremony, err := v.BeginRegistration(userHandle, nil) | |
| 408 | + | require.NoError(t, err) | |
| 409 | + | parsed, err := virtualwebauthn.ParseAttestationOptions(string(options)) | |
| 410 | + | require.NoError(t, err) | |
| 411 | + | response := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *parsed) | |
| 412 | + | if tc.ceremony != nil { | |
| 413 | + | ceremony = tc.ceremony(t, ceremony) | |
| 414 | + | } | |
| 415 | + | handle := userHandle | |
| 416 | + | if tc.handle != nil { | |
| 417 | + | handle = tc.handle | |
| 418 | + | } | |
| 419 | + | _, err = v.FinishRegistration(handle, ceremony, []byte(response)) | |
| 420 | + | require.Error(t, err) | |
| 421 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 422 | + | }) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | t.Run("garbage", func(t *testing.T) { | |
| 426 | + | t.Parallel() | |
| 427 | + | _, ceremony, err := v.BeginRegistration(userHandle, nil) | |
| 428 | + | require.NoError(t, err) | |
| 429 | + | _, err = v.FinishRegistration(userHandle, ceremony, []byte("{")) | |
| 430 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 431 | + | _, err = v.FinishRegistration(userHandle, []byte("{"), []byte("{}")) | |
| 432 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 433 | + | }) | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func TestNew(t *testing.T) { | |
| 437 | + | t.Parallel() | |
| 438 | + | for _, ok := range []string{"https://git.example.com", "http://localhost:7341", "https://Git.Example.com:8443/"} { | |
| 439 | + | _, err := webauthn.New(ok) | |
| 440 | + | assert.NoError(t, err, ok) | |
| 441 | + | } | |
| 442 | + | for _, bad := range []string{"http://127.0.0.1:7341", "http://[::1]:7341", "git.example.com", "ftp://git.example.com", "", "https://"} { | |
| 443 | + | _, err := webauthn.New(bad) | |
| 444 | + | assert.Error(t, err, bad) | |
| 445 | + | } | |
| 446 | + | } | |
M internal/app/cli_e2e_test.go +7 -4
| @@ -309,9 +309,11 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 309 | 309 | res = c.cli(t, "", "repo", "list", "--json").ok(t) | |
| 310 | 310 | assert.JSONEq(t, `[]`, res.stdout) | |
| 311 | 311 | ||
| 312 | - | // version: no token needed, nothing missing. | |
| 312 | + | // version: no token needed. This server runs on http://127.0.0.1, where | |
| 313 | + | // passkeys are not available: they are the only feature missing. | |
| 313 | 314 | res = c.cli(t, "", "version", "--json").ok(t) | |
| 314 | - | assert.Empty(t, res.stderr, "no missing-feature warning") | |
| 315 | + | assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr) | |
| 316 | + | assert.Contains(t, res.stderr, "the server does not support passkeys") | |
| 315 | 317 | type versionOut struct { | |
| 316 | 318 | Client struct{ Version string } | |
| 317 | 319 | Server struct { | |
| @@ -325,11 +327,12 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 325 | 327 | assert.Equal(t, c.url, ver.Server.URL) | |
| 326 | 328 | assert.NotEmpty(t, ver.Server.Version) | |
| 327 | 329 | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features) | |
| 328 | - | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | |
| 330 | + | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 329 | 331 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 330 | 332 | require.Equal(t, 0, code, stderr) | |
| 331 | 333 | assert.Contains(t, stdout, "server") | |
| 332 | - | assert.Empty(t, stderr) | |
| 334 | + | assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr) | |
| 335 | + | assert.Contains(t, stderr, "the server does not support passkeys") | |
| 333 | 336 | ||
| 334 | 337 | // Tokens: create a second one, list, revoke it, see it stop working. | |
| 335 | 338 | // (through the binary: the in-process runs use a fixed clock) | |
M internal/app/cli_fake_test.go +12 -0
| @@ -31,6 +31,7 @@ type fakeAPI struct { | |||
| 31 | 31 | ermiteconnect.UnimplementedServerServiceHandler | |
| 32 | 32 | ermiteconnect.UnimplementedEventServiceHandler | |
| 33 | 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 34 | + | ermiteconnect.UnimplementedSessionServiceHandler | |
| 34 | 35 | ermiteconnect.UnimplementedRunServiceHandler | |
| 35 | 36 | ||
| 36 | 37 | mu sync.Mutex | |
| @@ -58,6 +59,9 @@ type fakeAPI struct { | |||
| 58 | 59 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | |
| 59 | 60 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | |
| 60 | 61 | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | |
| 62 | + | ||
| 63 | + | listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) | |
| 64 | + | removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) | |
| 61 | 65 | } | |
| 62 | 66 | ||
| 63 | 67 | func (f *fakeAPI) record(name string, h http.Header, req any) { | |
| @@ -140,6 +144,12 @@ func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkRead | |||
| 140 | 144 | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | |
| 141 | 145 | return respond(f, "GetSigningKey", r, f.getSigningKey) | |
| 142 | 146 | } | |
| 147 | + | func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) { | |
| 148 | + | return respond(f, "ListPasskeys", r, f.listPasskeys) | |
| 149 | + | } | |
| 150 | + | func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) { | |
| 151 | + | return respond(f, "RemovePasskey", r, f.removePasskey) | |
| 152 | + | } | |
| 143 | 153 | func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error { | |
| 144 | 154 | f.record("Watch", r.Header(), r.Msg) | |
| 145 | 155 | if f.watch == nil { | |
| @@ -163,6 +173,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||
| 163 | 173 | mount(p, h) | |
| 164 | 174 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | |
| 165 | 175 | mount(p, h) | |
| 176 | + | p, h = ermiteconnect.NewSessionServiceHandler(f) | |
| 177 | + | mount(p, h) | |
| 166 | 178 | p, h = ermiteconnect.NewRunServiceHandler(f) | |
| 167 | 179 | mount(p, h) | |
| 168 | 180 | srv := httptest.NewServer(mux) | |
M internal/app/cli_version.go +1 -0
| @@ -116,4 +116,5 @@ var featureUse = map[string]string{ | |||
| 116 | 116 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 117 | 117 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 118 | 118 | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | |
| 119 | + | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | |
| 119 | 120 | } | |
M internal/app/cli_version_test.go +20 -5
| @@ -4,6 +4,7 @@ package app | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 9 | 10 | "connectrpc.com/connect" | |
| @@ -14,7 +15,7 @@ import ( | |||
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | // allFeatures are the features this client uses. | |
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners"} | |
| 18 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys"} | |
| 18 | 19 | ||
| 19 | 20 | func versionFake(features ...string) *fakeAPI { | |
| 20 | 21 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| @@ -35,15 +36,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 35 | 36 | f := versionFake(allFeatures...) | |
| 36 | 37 | url := f.serve(t) | |
| 37 | 38 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners\n", res.stdout) | |
| 39 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, passkeys\n", res.stdout) | |
| 39 | 40 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 40 | 41 | ||
| 41 | 42 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout) | |
| 43 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | 46 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 46 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners") | |
| 47 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys") | |
| 47 | 48 | url := f.serve(t) | |
| 48 | 49 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 49 | 50 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -51,13 +52,27 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 51 | 52 | assert.NotContains(t, res.stderr, "repo.update") | |
| 52 | 53 | ||
| 53 | 54 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 55 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 55 | 56 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 56 | 57 | ||
| 57 | 58 | f = versionFake() | |
| 58 | 59 | res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t) | |
| 59 | 60 | assert.Contains(t, res.stderr, "repo.update") | |
| 60 | 61 | assert.Contains(t, res.stderr, "events.resume") | |
| 62 | + | assert.Contains(t, res.stderr, "passkeys") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // A server without passkeys (http public URL, or an IP address host) works for | |
| 66 | + | // everything else: only `ermite web passkey` is warned about. | |
| 67 | + | func TestVersionWarnsAboutMissingPasskeys(t *testing.T) { | |
| 68 | + | f := versionFake(allFeatures[:len(allFeatures)-1]...) | |
| 69 | + | res := runCLI(t, envFor(f.serve(t)), "", "version", "--json").ok(t) | |
| 70 | + | assert.Contains(t, res.stdout, `"missing_features": [ | |
| 71 | + | "passkeys" | |
| 72 | + | ]`) | |
| 73 | + | assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr) | |
| 74 | + | assert.Contains(t, res.stderr, "the server does not support passkeys, which `ermite web passkey` needs") | |
| 75 | + | assert.Contains(t, res.stderr, "https public URL") | |
| 61 | 76 | } | |
| 62 | 77 | ||
| 63 | 78 | func TestVersionServerFailureStillShowsClient(t *testing.T) { | |
M internal/app/cli_web.go +117 -3
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | ||
| @@ -10,6 +11,7 @@ import ( | |||
| 10 | 11 | "github.com/spf13/cobra" | |
| 11 | 12 | ||
| 12 | 13 | "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 17 | // jsonLoginLink is the stable JSON shape of `ermite web login --json`. | |
| @@ -22,10 +24,10 @@ func newWebCmd(env *cliEnv) *cobra.Command { | |||
| 22 | 24 | web := &cobra.Command{ | |
| 23 | 25 | Use: "web", | |
| 24 | 26 | Short: "Sign the owner in to the web interface of a remote server", | |
| 25 | - | Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" + | |
| 26 | - | "token that holds every scope and is limited neither to repositories nor to branches.", | |
| 27 | + | Long: "Manage the owner's browser sessions and passkeys on a remote server. They need the server's admin\n" + | |
| 28 | + | "token, or a token that holds every scope and is limited neither to repositories nor to branches.", | |
| 27 | 29 | } | |
| 28 | - | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env)) | |
| 30 | + | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env)) | |
| 29 | 31 | return web | |
| 30 | 32 | } | |
| 31 | 33 | ||
| @@ -87,3 +89,115 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command { | |||
| 87 | 89 | } | |
| 88 | 90 | return cmd | |
| 89 | 91 | } | |
| 92 | + | ||
| 93 | + | // jsonPasskey is the stable JSON shape of a passkey. It never holds key | |
| 94 | + | // material. | |
| 95 | + | type jsonPasskey struct { | |
| 96 | + | ID string `json:"id"` | |
| 97 | + | Name string `json:"name"` | |
| 98 | + | CreatedAt *string `json:"created_at"` | |
| 99 | + | LastUsedAt *string `json:"last_used_at"` | |
| 100 | + | Synced bool `json:"synced"` | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | const jsonPasskeyHelp = "\n\nJSON fields (--json): id (credential id, base64url), name, created_at (RFC 3339 UTC), last_used_at\n" + | |
| 104 | + | "(null if never used), synced (the authenticator reports the passkey as backed up)." | |
| 105 | + | ||
| 106 | + | func newWebPasskeyCmd(env *cliEnv) *cobra.Command { | |
| 107 | + | cmd := &cobra.Command{ | |
| 108 | + | Use: "passkey", | |
| 109 | + | Short: "List or remove the passkeys that sign the owner in", | |
| 110 | + | Long: "List or remove the passkeys that sign the owner in to the web interface. Adding one needs a browser:\n" + | |
| 111 | + | "sign in (`ermite web login`), then open /-/passkeys. Passkeys are only available when the server's\n" + | |
| 112 | + | "public URL is https (or localhost) with a host name, not an IP address.", | |
| 113 | + | } | |
| 114 | + | cmd.AddCommand(newWebPasskeyListCmd(env), newWebPasskeyRemoveCmd(env)) | |
| 115 | + | return cmd | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func listPasskeys(ctx context.Context, c *client.Client) ([]*ermite.Passkey, error) { | |
| 119 | + | res, err := c.Sessions().ListPasskeys(ctx, connect.NewRequest(&ermite.ListPasskeysRequest{})) | |
| 120 | + | if err != nil { | |
| 121 | + | return nil, c.Describe(err) | |
| 122 | + | } | |
| 123 | + | return res.Msg.GetPasskeys(), nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func newWebPasskeyListCmd(env *cliEnv) *cobra.Command { | |
| 127 | + | cmd := &cobra.Command{ | |
| 128 | + | Use: "list", | |
| 129 | + | Short: "List the registered passkeys", | |
| 130 | + | Long: "List the passkeys, oldest first, with the id to give to `ermite web passkey remove`." + jsonPasskeyHelp + | |
| 131 | + | "\n--json prints an array of passkeys.", | |
| 132 | + | Args: cobra.NoArgs, | |
| 133 | + | } | |
| 134 | + | asJSON := addJSONFlag(cmd) | |
| 135 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 136 | + | c, err := env.client(cmd) | |
| 137 | + | if err != nil { | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | passkeys, err := listPasskeys(env.ctx(cmd), c) | |
| 141 | + | if err != nil { | |
| 142 | + | return err | |
| 143 | + | } | |
| 144 | + | if *asJSON { | |
| 145 | + | out := make([]jsonPasskey, 0, len(passkeys)) | |
| 146 | + | for _, p := range passkeys { | |
| 147 | + | out = append(out, jsonPasskey{ | |
| 148 | + | ID: p.GetId(), Name: p.GetName(), CreatedAt: jsonTime(p.GetCreatedAt()), | |
| 149 | + | LastUsedAt: jsonTime(p.GetLastUsedAt()), Synced: p.GetSynced(), | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 153 | + | } | |
| 154 | + | if len(passkeys) == 0 { | |
| 155 | + | noticef(cmd, "no passkeys") | |
| 156 | + | return nil | |
| 157 | + | } | |
| 158 | + | rows := make([][]string, 0, len(passkeys)) | |
| 159 | + | for _, p := range passkeys { | |
| 160 | + | synced := "no" | |
| 161 | + | if p.GetSynced() { | |
| 162 | + | synced = "yes" | |
| 163 | + | } | |
| 164 | + | rows = append(rows, []string{ | |
| 165 | + | p.GetId(), p.GetName(), shortTime(p.GetCreatedAt(), "-"), shortTime(p.GetLastUsedAt(), "never"), synced, | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | return table(cmd.OutOrStdout(), []string{"ID", "NAME", "CREATED", "LAST USED", "SYNCED"}, rows) | |
| 169 | + | } | |
| 170 | + | return cmd | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func newWebPasskeyRemoveCmd(env *cliEnv) *cobra.Command { | |
| 174 | + | return &cobra.Command{ | |
| 175 | + | Use: "remove <id>", | |
| 176 | + | Short: "Remove a passkey and sign every browser out", | |
| 177 | + | Long: "Remove a passkey by id (see `ermite web passkey list`). It can no longer sign in. Removing one also\n" + | |
| 178 | + | "signs every browser out, like `ermite web logout --all`: web sessions are not tied to the passkey that\n" + | |
| 179 | + | "opened them. Sign in again with another passkey or `ermite web login`.", | |
| 180 | + | Args: cobra.ExactArgs(1), | |
| 181 | + | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { | |
| 182 | + | if len(args) > 0 { | |
| 183 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 184 | + | } | |
| 185 | + | ids := complete(env, cmd, toComplete, listPasskeys, func(p *ermite.Passkey) (string, string) { | |
| 186 | + | return p.GetId(), clean(p.GetName()) | |
| 187 | + | }) | |
| 188 | + | return ids, cobra.ShellCompDirectiveNoFileComp | |
| 189 | + | }, | |
| 190 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 191 | + | c, err := env.client(cmd) | |
| 192 | + | if err != nil { | |
| 193 | + | return err | |
| 194 | + | } | |
| 195 | + | if _, err := c.Sessions().RemovePasskey(env.ctx(cmd), connect.NewRequest(&ermite.RemovePasskeyRequest{Id: args[0]})); err != nil { | |
| 196 | + | return c.Describe(err) | |
| 197 | + | } | |
| 198 | + | noticef(cmd, "passkey removed; every browser is signed out") | |
| 199 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", args[0]) | |
| 200 | + | return err | |
| 201 | + | }, | |
| 202 | + | } | |
| 203 | + | } | |
A internal/app/cli_web_test.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func twoPasskeys() *ermite.ListPasskeysResponse { | |
| 17 | + | return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{ | |
| 18 | + | {Id: "AQID", Name: "Pixel 9", CreatedAt: ts("2026-10-01T08:00:00Z"), LastUsedAt: ts("2026-10-09T18:30:00Z"), Synced: true}, | |
| 19 | + | {Id: "BAUG", Name: "yubikey", CreatedAt: ts("2026-10-02T09:15:00Z")}, | |
| 20 | + | }} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestWebPasskeyList(t *testing.T) { | |
| 24 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }} | |
| 25 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t) | |
| 26 | + | assert.Empty(t, res.stderr) | |
| 27 | + | assert.Regexp(t, `(?m)^ID\s+NAME\s+CREATED\s+LAST USED\s+SYNCED$`, res.stdout) | |
| 28 | + | assert.Regexp(t, `(?m)^AQID\s+Pixel 9\s+2026-10-01 08:00\s+2026-10-09 18:30\s+yes$`, res.stdout) | |
| 29 | + | assert.Regexp(t, `(?m)^BAUG\s+yubikey\s+2026-10-02 09:15\s+never\s+no$`, res.stdout) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestWebPasskeyListEmpty(t *testing.T) { | |
| 33 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { | |
| 34 | + | return &ermite.ListPasskeysResponse{}, nil | |
| 35 | + | }} | |
| 36 | + | url := f.serve(t) | |
| 37 | + | res := runCLI(t, envFor(url), "", "web", "passkey", "list").ok(t) | |
| 38 | + | assert.Empty(t, res.stdout) | |
| 39 | + | assert.Contains(t, res.stderr, "no passkeys") | |
| 40 | + | ||
| 41 | + | res = runCLI(t, envFor(url), "", "web", "passkey", "list", "--json").ok(t) | |
| 42 | + | assert.JSONEq(t, `[]`, res.stdout) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestWebPasskeyListJSON(t *testing.T) { | |
| 46 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }} | |
| 47 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list", "--json").ok(t) | |
| 48 | + | assert.Empty(t, res.stderr) | |
| 49 | + | assert.JSONEq(t, `[ | |
| 50 | + | {"id":"AQID","name":"Pixel 9","created_at":"2026-10-01T08:00:00Z","last_used_at":"2026-10-09T18:30:00Z","synced":true}, | |
| 51 | + | {"id":"BAUG","name":"yubikey","created_at":"2026-10-02T09:15:00Z","last_used_at":null,"synced":false} | |
| 52 | + | ]`, res.stdout) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestWebPasskeyListCleansServerText(t *testing.T) { | |
| 56 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { | |
| 57 | + | return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{{Id: "AQ", Name: "evil\x1b[2J name", CreatedAt: ts("2026-10-01T08:00:00Z")}}}, nil | |
| 58 | + | }} | |
| 59 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t) | |
| 60 | + | assert.NotContains(t, res.stdout, "\x1b") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestWebPasskeyRemove(t *testing.T) { | |
| 64 | + | f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) { | |
| 65 | + | return &ermite.RemovePasskeyResponse{}, nil | |
| 66 | + | }} | |
| 67 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID").ok(t) | |
| 68 | + | assert.Equal(t, "removed AQID\n", res.stdout) | |
| 69 | + | assert.Contains(t, res.stderr, "every browser is signed out") | |
| 70 | + | ||
| 71 | + | calls, reqs := f.snapshot() | |
| 72 | + | assert.Equal(t, []string{"RemovePasskey"}, calls) | |
| 73 | + | assert.Equal(t, "AQID", reqs[0].(*ermite.RemovePasskeyRequest).GetId()) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestWebPasskeyRemoveErrors(t *testing.T) { | |
| 77 | + | for name, tc := range map[string]struct { | |
| 78 | + | code connect.Code | |
| 79 | + | msg string | |
| 80 | + | want string | |
| 81 | + | }{ | |
| 82 | + | "unknown id": {connect.CodeNotFound, "not found", "not found"}, | |
| 83 | + | "not the owner": {connect.CodePermissionDenied, "", "permission denied"}, | |
| 84 | + | "unavailable": {connect.CodeFailedPrecondition, "passkeys are not available on this server", "passkeys are not available on this server"}, | |
| 85 | + | "bad id": {connect.CodeInvalidArgument, "id is not a base64url credential id", "invalid request"}, | |
| 86 | + | } { | |
| 87 | + | t.Run(name, func(t *testing.T) { | |
| 88 | + | f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) { | |
| 89 | + | return nil, connect.NewError(tc.code, errors.New(tc.msg)) | |
| 90 | + | }} | |
| 91 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID") | |
| 92 | + | require.Error(t, res.err) | |
| 93 | + | assert.Contains(t, res.err.Error(), tc.want) | |
| 94 | + | assert.Empty(t, res.stdout, "nothing is reported as removed") | |
| 95 | + | assert.NotContains(t, res.stderr, "signed out") | |
| 96 | + | }) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestWebPasskeyListUnavailable(t *testing.T) { | |
| 101 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { | |
| 102 | + | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server")) | |
| 103 | + | }} | |
| 104 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list") | |
| 105 | + | require.Error(t, res.err) | |
| 106 | + | assert.Contains(t, res.err.Error(), "passkeys are not available on this server") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestWebPasskeyArgs(t *testing.T) { | |
| 110 | + | f := &fakeAPI{} | |
| 111 | + | url := f.serve(t) | |
| 112 | + | for _, args := range [][]string{ | |
| 113 | + | {"web", "passkey", "remove"}, | |
| 114 | + | {"web", "passkey", "remove", "a", "b"}, | |
| 115 | + | {"web", "passkey", "list", "extra"}, | |
| 116 | + | } { | |
| 117 | + | res := runCLI(t, envFor(url), "", args...) | |
| 118 | + | require.Error(t, res.err, args) | |
| 119 | + | } | |
| 120 | + | calls, _ := f.snapshot() | |
| 121 | + | assert.Empty(t, calls, "nothing reached the server") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // The ids complete from the server's list. | |
| 125 | + | func TestWebPasskeyRemoveCompletesIDs(t *testing.T) { | |
| 126 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }} | |
| 127 | + | res := runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "").ok(t) | |
| 128 | + | assert.Contains(t, res.stdout, "AQID\tPixel 9") | |
| 129 | + | assert.Contains(t, res.stdout, "BAUG\tyubikey") | |
| 130 | + | ||
| 131 | + | res = runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "B").ok(t) | |
| 132 | + | assert.NotContains(t, res.stdout, "AQID") | |
| 133 | + | assert.Contains(t, res.stdout, "BAUG\tyubikey") | |
| 134 | + | } | |
M internal/app/e2e_helpers_test.go +5 -0
| @@ -48,6 +48,8 @@ type harnessOpts struct { | |||
| 48 | 48 | eventBuffer int | |
| 49 | 49 | // revalidateEvery overrides how often a watcher's token is re-checked. | |
| 50 | 50 | revalidateEvery time.Duration | |
| 51 | + | // publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set. | |
| 52 | + | publicURL string | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | 55 | // newHarness starts a forge. The data directory is short: the hook socket | |
| @@ -72,6 +74,9 @@ func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness | |||
| 72 | 74 | config.EnvGit: gitBin, | |
| 73 | 75 | config.EnvAddr: "127.0.0.1:1", | |
| 74 | 76 | } | |
| 77 | + | if o.publicURL != "" { | |
| 78 | + | env[config.EnvPublicURL] = o.publicURL | |
| 79 | + | } | |
| 75 | 80 | if o.trustedProxies != "" { | |
| 76 | 81 | env[config.EnvTrustedProxies] = o.trustedProxies | |
| 77 | 82 | } | |
M internal/app/forge.go +24 -1
| @@ -25,6 +25,7 @@ import ( | |||
| 25 | 25 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 26 | 26 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 27 | 27 | "bnema.dev/ermite/internal/adapters/web" | |
| 28 | + | "bnema.dev/ermite/internal/adapters/webauthn" | |
| 28 | 29 | "bnema.dev/ermite/internal/clientlimit" | |
| 29 | 30 | "bnema.dev/ermite/internal/config" | |
| 30 | 31 | "bnema.dev/ermite/internal/domain" | |
| @@ -77,7 +78,9 @@ type forge struct { | |||
| 77 | 78 | MergeRequests *usecase.MergeRequests | |
| 78 | 79 | SigningKey *usecase.SigningKey | |
| 79 | 80 | Sessions *usecase.Sessions | |
| 80 | - | EventLog *usecase.Events | |
| 81 | + | // Passkeys is nil when passkeys are not available (passkeysAvailable). | |
| 82 | + | Passkeys *usecase.Passkeys | |
| 83 | + | EventLog *usecase.Events | |
| 81 | 84 | // CI is the CI engine: workflow trigger, planner, runner protocol and the | |
| 82 | 85 | // owner side. It starts with the server and stops with it. | |
| 83 | 86 | CI *usecase.CI | |
| @@ -231,9 +234,28 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 231 | 234 | return nil, err | |
| 232 | 235 | } | |
| 233 | 236 | ||
| 237 | + | // Owner passkeys (ADR 0026): built only when the public URL allows them. | |
| 238 | + | // When it does, a verifier that cannot be built is fatal. | |
| 239 | + | if ok, reason := passkeysAvailable(cfg.PublicURL); ok { | |
| 240 | + | verifier, err := webauthn.New(cfg.PublicURL) | |
| 241 | + | if err != nil { | |
| 242 | + | return nil, fmt.Errorf("passkeys: %w", err) | |
| 243 | + | } | |
| 244 | + | f.Passkeys = usecase.NewPasskeys(store.Passkeys(), verifier, f.Sessions, clock) | |
| 245 | + | log.Info().Str("public_url", cfg.PublicURL).Msg("passkeys enabled") | |
| 246 | + | } else { | |
| 247 | + | log.Info().Str("reason", reason).Msg("passkeys disabled") | |
| 248 | + | } | |
| 249 | + | ||
| 234 | 250 | // One budget of failed authentications per client, for Git, the API and | |
| 235 | 251 | // the web login. | |
| 236 | 252 | limiter := clientlimit.NewLimiter(nil) | |
| 253 | + | // A nil *usecase.Passkeys must not become a non-nil interface: the API | |
| 254 | + | // tests for nil to answer failed_precondition and to leave the feature out. | |
| 255 | + | var apiPasskeys ports.PasskeyService | |
| 256 | + | if f.Passkeys != nil { | |
| 257 | + | apiPasskeys = f.Passkeys | |
| 258 | + | } | |
| 237 | 259 | f.API = rpc.New(rpc.Config{ | |
| 238 | 260 | Auth: f.Auth, | |
| 239 | 261 | Repos: f.Repos, | |
| @@ -243,6 +265,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 243 | 265 | MergeRequests: f.MergeRequests, | |
| 244 | 266 | SigningKey: f.SigningKey, | |
| 245 | 267 | Sessions: f.Sessions, | |
| 268 | + | Passkeys: apiPasskeys, | |
| 246 | 269 | Runner: f.CI, | |
| 247 | 270 | Runs: f.CI, | |
| 248 | 271 | Version: serverVersion(), | |
M internal/app/testdata/web/blob-binary.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/blob-code.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/blob-empty.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/blob-image.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/blob-markdown.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/blob-svg.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/blob-text.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/blob-too-large.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/blob-trojan.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commit-big.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commit-binary.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commit-merge.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commit-modify-split.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commit-modify.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commit-rename.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commit-root.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commit-trojan.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commits-page2.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commits-path.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commits-root.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/commits-small.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/empty.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/home.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/not-found-page.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/not-found.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/refs.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/summary.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/tree-nested.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/tree-root.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/tree-slash-ref.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/testdata/web/tree-subdir.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | 25 | </script> | |
| 24 | 26 | </head> | |
M internal/app/web.go +65 -9
| @@ -29,6 +29,12 @@ import ( | |||
| 29 | 29 | // checks it with sessions); every other request is anonymous. The web passes | |
| 30 | 30 | // the principal of the request to each use case call itself. | |
| 31 | 31 | func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) { | |
| 32 | + | // A nil *usecase.Passkeys must not become a non-nil interface (the web | |
| 33 | + | // tests for nil to leave the passkey routes out). | |
| 34 | + | var passkeys ports.WebPasskeyService | |
| 35 | + | if f.Passkeys != nil { | |
| 36 | + | passkeys = webPasskeys{f.Passkeys} | |
| 37 | + | } | |
| 32 | 38 | return web.New(web.Config{ | |
| 33 | 39 | Repos: f.Repos, | |
| 34 | 40 | Browse: f.Browse, | |
| @@ -40,6 +46,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 40 | 46 | Version: serverVersion(), | |
| 41 | 47 | ||
| 42 | 48 | Sessions: webSessions{f.Sessions}, | |
| 49 | + | Passkeys: passkeys, | |
| 43 | 50 | Limiter: limiter, | |
| 44 | 51 | TrustedProxies: cfg.TrustedProxies, | |
| 45 | 52 | }) | |
| @@ -62,6 +69,25 @@ func sessionsNeedHTTPS(publicURL string) bool { | |||
| 62 | 69 | return err != nil || !ip.Unmap().IsLoopback() | |
| 63 | 70 | } | |
| 64 | 71 | ||
| 72 | + | // passkeysAvailable reports whether passkeys can work on this public URL, and | |
| 73 | + | // if not, why. WebAuthn needs a secure context (https, or localhost) and a | |
| 74 | + | // relying party ID that is a DNS name, not an IP address, so a server on | |
| 75 | + | // http://127.0.0.1 starts without passkeys. A sign-in needs the session | |
| 76 | + | // cookie, hence the same https rule as sessionsNeedHTTPS. | |
| 77 | + | func passkeysAvailable(publicURL string) (ok bool, reason string) { | |
| 78 | + | if sessionsNeedHTTPS(publicURL) { | |
| 79 | + | return false, "needs https" | |
| 80 | + | } | |
| 81 | + | u, err := url.Parse(publicURL) | |
| 82 | + | if err != nil || u.Hostname() == "" { | |
| 83 | + | return false, "public URL has no host" | |
| 84 | + | } | |
| 85 | + | if _, err := netip.ParseAddr(strings.ToLower(u.Hostname())); err == nil { | |
| 86 | + | return false, "public URL host is an IP address" | |
| 87 | + | } | |
| 88 | + | return true, "" | |
| 89 | + | } | |
| 90 | + | ||
| 65 | 91 | // warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will | |
| 66 | 92 | // not work on an http public URL. It does not stop the server: the pages, | |
| 67 | 93 | // the API and Git do not need the cookie. | |
| @@ -107,20 +133,27 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura | |||
| 107 | 133 | if err != nil { | |
| 108 | 134 | return "", 0, err | |
| 109 | 135 | } | |
| 110 | - | // The cookie lasts as long as the session, which the expiry of the token | |
| 111 | - | // that made the link may cut short. The session token carries its own | |
| 112 | - | // expiry, so a cookie that outlives it by under a second is harmless. | |
| 113 | - | // The expiry is truncated to the second, hence the slack on the full TTL. | |
| 114 | - | lifetime := usecase.SessionTTL | |
| 115 | - | if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second { | |
| 116 | - | lifetime = d.Round(time.Second) | |
| 117 | - | } | |
| 118 | - | if lifetime < time.Second { | |
| 136 | + | lifetime, ok := cookieLifetime(expiresAt) | |
| 137 | + | if !ok { | |
| 119 | 138 | return "", 0, ports.ErrLinkRejected | |
| 120 | 139 | } | |
| 121 | 140 | return token, lifetime, nil | |
| 122 | 141 | } | |
| 123 | 142 | ||
| 143 | + | // cookieLifetime is how long the session cookie lasts for a session that | |
| 144 | + | // expires at expiresAt: as long as the session, which the expiry of the token | |
| 145 | + | // that made a login link may cut short. The session token carries its own | |
| 146 | + | // expiry, so a cookie that outlives it by under a second is harmless. The | |
| 147 | + | // expiry is truncated to the second, hence the slack on the full TTL. ok is | |
| 148 | + | // false when under a second is left: there is no session to give. | |
| 149 | + | func cookieLifetime(expiresAt time.Time) (lifetime time.Duration, ok bool) { | |
| 150 | + | lifetime = usecase.SessionTTL | |
| 151 | + | if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second { | |
| 152 | + | lifetime = d.Round(time.Second) | |
| 153 | + | } | |
| 154 | + | return lifetime, lifetime >= time.Second | |
| 155 | + | } | |
| 156 | + | ||
| 124 | 157 | func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal, bool, error) { | |
| 125 | 158 | p, err := s.Sessions.Verify(ctx, token) | |
| 126 | 159 | if errors.Is(err, domain.ErrUnauthenticated) { | |
| @@ -132,6 +165,29 @@ func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal | |||
| 132 | 165 | return p, true, nil | |
| 133 | 166 | } | |
| 134 | 167 | ||
| 168 | + | // webPasskeys adapts the passkey use cases to ports.WebPasskeyService. Only | |
| 169 | + | // FinishLogin differs from *usecase.Passkeys: the session a passkey sign-in | |
| 170 | + | // opens is given to the web as a token and a lifetime, like webSessions.Login | |
| 171 | + | // does for a link, and a refusal is ports.ErrLinkRejected. | |
| 172 | + | type webPasskeys struct{ *usecase.Passkeys } | |
| 173 | + | ||
| 174 | + | var _ ports.WebPasskeyService = webPasskeys{} | |
| 175 | + | ||
| 176 | + | func (p webPasskeys) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) { | |
| 177 | + | token, expiresAt, err := p.Passkeys.FinishLogin(ctx, ceremony, response) | |
| 178 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 179 | + | return "", 0, ports.ErrLinkRejected | |
| 180 | + | } | |
| 181 | + | if err != nil { | |
| 182 | + | return "", 0, err | |
| 183 | + | } | |
| 184 | + | lifetime, ok := cookieLifetime(expiresAt) | |
| 185 | + | if !ok { | |
| 186 | + | return "", 0, ports.ErrLinkRejected | |
| 187 | + | } | |
| 188 | + | return token, lifetime, nil | |
| 189 | + | } | |
| 190 | + | ||
| 135 | 191 | // webRoutes mounts the web on the patterns it serves. | |
| 136 | 192 | func webRoutes(h http.Handler) []httpserver.Route { | |
| 137 | 193 | routes := make([]httpserver.Route, 0, len(web.Patterns)) | |
A internal/app/web_adapters_test.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | + | "bnema.dev/ermite/internal/usecase" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // cookieLifetime: the session lifetime, cut short by an expiry that comes | |
| 22 | + | // first, and no session at all when under a second is left. | |
| 23 | + | func TestCookieLifetime(t *testing.T) { | |
| 24 | + | t.Parallel() | |
| 25 | + | now := time.Now() | |
| 26 | + | tests := []struct { | |
| 27 | + | name string | |
| 28 | + | expires time.Time | |
| 29 | + | want time.Duration // approximate, within a second | |
| 30 | + | ok bool | |
| 31 | + | }{ | |
| 32 | + | {"a full session", now.Add(usecase.SessionTTL), usecase.SessionTTL, true}, | |
| 33 | + | {"the expiry is truncated to the second: the slack counts as full", now.Add(usecase.SessionTTL - 500*time.Millisecond), usecase.SessionTTL, true}, | |
| 34 | + | {"cut short by the token that made the link", now.Add(30 * time.Minute), 30 * time.Minute, true}, | |
| 35 | + | {"two seconds left", now.Add(2 * time.Second), 2 * time.Second, true}, | |
| 36 | + | {"under a second left", now.Add(400 * time.Millisecond), 0, false}, | |
| 37 | + | {"already over", now.Add(-time.Hour), 0, false}, | |
| 38 | + | } | |
| 39 | + | for _, tc := range tests { | |
| 40 | + | t.Run(tc.name, func(t *testing.T) { | |
| 41 | + | t.Parallel() | |
| 42 | + | got, ok := cookieLifetime(tc.expires) | |
| 43 | + | assert.Equal(t, tc.ok, ok) | |
| 44 | + | if tc.ok { | |
| 45 | + | assert.InDelta(t, tc.want.Seconds(), got.Seconds(), 1.5) | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // webPasskeys.FinishLogin turns the use case's answer into the web's: a refusal | |
| 52 | + | // is ports.ErrLinkRejected, any other failure is passed on, and a success has | |
| 53 | + | // the cookie lifetime of its session. | |
| 54 | + | func TestWebPasskeysFinishLogin(t *testing.T) { | |
| 55 | + | t.Parallel() | |
| 56 | + | ctx := context.Background() | |
| 57 | + | boom := errors.New("disk on fire") | |
| 58 | + | ||
| 59 | + | // newPasskeys builds the use cases over mocks; the verifier answers verify. | |
| 60 | + | newPasskeys := func(t *testing.T, verify func() (domain.Passkey, error), stored domain.Passkey) (webPasskeys, string) { | |
| 61 | + | t.Helper() | |
| 62 | + | clock := mocks.NewClock(t) | |
| 63 | + | clock.EXPECT().Now().RunAndReturn(time.Now).Maybe() | |
| 64 | + | sessionStore := mocks.NewSessionStore(t) | |
| 65 | + | sessionStore.EXPECT().Key(mock.Anything).Return([]byte("0123456789abcdef0123456789abcdef"), nil) | |
| 66 | + | sessionStore.EXPECT().Epoch(mock.Anything).Return(uint64(0), nil).Maybe() | |
| 67 | + | sessions, err := usecase.NewSessions(ctx, sessionStore, clock, "https://forge.example") | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | ||
| 70 | + | store := mocks.NewPasskeyStore(t) | |
| 71 | + | store.EXPECT().UserHandle(mock.Anything).Return([]byte("handle"), nil).Maybe() | |
| 72 | + | store.EXPECT().Get(mock.Anything, mock.Anything).Return(stored, nil).Maybe() | |
| 73 | + | store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 74 | + | verifier := mocks.NewPasskeyVerifier(t) | |
| 75 | + | verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("c"), nil) | |
| 76 | + | verifier.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 77 | + | RunAndReturn(func([]byte, []byte, []byte, func([]byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 78 | + | return verify() | |
| 79 | + | }).Maybe() // not reached for an unknown ceremony | |
| 80 | + | pk := webPasskeys{usecase.NewPasskeys(store, verifier, sessions, clock)} | |
| 81 | + | id, _, err := pk.BeginLogin(ctx, "192.0.2.1") | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | return pk, id | |
| 84 | + | } | |
| 85 | + | stored := domain.Passkey{ID: []byte("cred-1")} | |
| 86 | + | ||
| 87 | + | t.Run("a refusal is ErrLinkRejected", func(t *testing.T) { | |
| 88 | + | t.Parallel() | |
| 89 | + | pk, id := newPasskeys(t, func() (domain.Passkey, error) { | |
| 90 | + | return domain.Passkey{}, domain.ErrUnauthenticated | |
| 91 | + | }, stored) | |
| 92 | + | token, lifetime, err := pk.FinishLogin(ctx, id, []byte(`{}`)) | |
| 93 | + | assert.ErrorIs(t, err, ports.ErrLinkRejected) | |
| 94 | + | assert.Empty(t, token) | |
| 95 | + | assert.Zero(t, lifetime) | |
| 96 | + | }) | |
| 97 | + | ||
| 98 | + | t.Run("an unknown ceremony is a refusal too", func(t *testing.T) { | |
| 99 | + | t.Parallel() | |
| 100 | + | pk, _ := newPasskeys(t, func() (domain.Passkey, error) { return stored, nil }, stored) | |
| 101 | + | _, _, err := pk.FinishLogin(ctx, "unknown", []byte(`{}`)) | |
| 102 | + | assert.ErrorIs(t, err, ports.ErrLinkRejected) | |
| 103 | + | }) | |
| 104 | + | ||
| 105 | + | t.Run("another error is passed on as it is", func(t *testing.T) { | |
| 106 | + | t.Parallel() | |
| 107 | + | pk, id := newPasskeys(t, func() (domain.Passkey, error) { return domain.Passkey{}, boom }, stored) | |
| 108 | + | token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`)) | |
| 109 | + | require.ErrorIs(t, err, boom) | |
| 110 | + | assert.NotErrorIs(t, err, ports.ErrLinkRejected) | |
| 111 | + | assert.Empty(t, token) | |
| 112 | + | }) | |
| 113 | + | ||
| 114 | + | t.Run("a success gives the token and the lifetime of the session", func(t *testing.T) { | |
| 115 | + | t.Parallel() | |
| 116 | + | pk, id := newPasskeys(t, func() (domain.Passkey, error) { return stored, nil }, stored) | |
| 117 | + | token, lifetime, err := pk.FinishLogin(ctx, id, []byte(`{}`)) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | assert.NotEmpty(t, token) | |
| 120 | + | assert.InDelta(t, usecase.SessionTTL.Seconds(), lifetime.Seconds(), 1.5) | |
| 121 | + | }) | |
| 122 | + | } | |
A internal/app/web_passkey_test.go +309 -0
| @@ -0,0 +1,309 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "crypto/sha256" | |
| 8 | + | "encoding/base64" | |
| 9 | + | "encoding/hex" | |
| 10 | + | "encoding/json" | |
| 11 | + | "net/http" | |
| 12 | + | "net/url" | |
| 13 | + | "strings" | |
| 14 | + | "testing" | |
| 15 | + | ||
| 16 | + | "github.com/descope/virtualwebauthn" | |
| 17 | + | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | const passkeyOrigin = "https://git.example.test" | |
| 24 | + | ||
| 25 | + | var passkeyRP = virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: passkeyOrigin} | |
| 26 | + | ||
| 27 | + | // passkeyHarness is a forge whose public URL allows passkeys, with a virtual | |
| 28 | + | // authenticator in front of its real web handler. | |
| 29 | + | type passkeyHarness struct { | |
| 30 | + | *cliHarness | |
| 31 | + | b browser | |
| 32 | + | auth virtualwebauthn.Authenticator | |
| 33 | + | cred virtualwebauthn.Credential | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func newPasskeyHarness(t *testing.T) *passkeyHarness { | |
| 37 | + | t.Helper() | |
| 38 | + | h := newHarness(t, harnessOpts{publicURL: passkeyOrigin}) | |
| 39 | + | c := &cliHarness{harness: h, url: h.srv.URL} | |
| 40 | + | return &passkeyHarness{ | |
| 41 | + | cliHarness: c, b: browser{h}, | |
| 42 | + | auth: virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{BackupEligible: true}), | |
| 43 | + | cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2), | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // postJSON posts a JSON body as the browser's script does. | |
| 48 | + | func (p *passkeyHarness) postJSON(path, token string, body any) page { | |
| 49 | + | p.t.Helper() | |
| 50 | + | var buf bytes.Buffer | |
| 51 | + | if body != nil { | |
| 52 | + | require.NoError(p.t, json.NewEncoder(&buf).Encode(body)) | |
| 53 | + | } | |
| 54 | + | req, err := http.NewRequest("POST", p.url+path, &buf) | |
| 55 | + | require.NoError(p.t, err) | |
| 56 | + | req.Header.Set("Origin", passkeyOrigin) | |
| 57 | + | req.Header.Set("Content-Type", "application/json") | |
| 58 | + | if token != "" { | |
| 59 | + | req.Header.Set("Cookie", sessionCookie+"="+token) | |
| 60 | + | } | |
| 61 | + | rec, err := http.DefaultClient.Do(req) | |
| 62 | + | require.NoError(p.t, err) | |
| 63 | + | defer func() { _ = rec.Body.Close() }() | |
| 64 | + | var out bytes.Buffer | |
| 65 | + | _, _ = out.ReadFrom(rec.Body) | |
| 66 | + | return page{status: rec.StatusCode, hdr: rec.Header, body: out.String()} | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // passkeyLoginPath turns the link the CLI printed into a path on the test | |
| 70 | + | // server (the harness here has an https public URL). | |
| 71 | + | func passkeyLoginPath(t *testing.T, link string) string { | |
| 72 | + | t.Helper() | |
| 73 | + | u, err := url.Parse(strings.TrimSpace(link)) | |
| 74 | + | require.NoError(t, err) | |
| 75 | + | assert.Equal(t, passkeyOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL") | |
| 76 | + | return u.Path | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | type beginReply struct { | |
| 80 | + | Ceremony string `json:"ceremony"` | |
| 81 | + | Options json.RawMessage `json:"options"` | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // register adds the authenticator's credential as the owner holding token. | |
| 85 | + | func (p *passkeyHarness) register(token, name string) page { | |
| 86 | + | p.t.Helper() | |
| 87 | + | begin := p.postJSON("/-/passkeys/register/begin", token, map[string]any{"name": name}) | |
| 88 | + | require.Equal(p.t, http.StatusOK, begin.status, begin.body) | |
| 89 | + | var br beginReply | |
| 90 | + | require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br)) | |
| 91 | + | opts, err := virtualwebauthn.ParseAttestationOptions(string(br.Options)) | |
| 92 | + | require.NoError(p.t, err) | |
| 93 | + | resp := virtualwebauthn.CreateAttestationResponse(passkeyRP, p.auth, p.cred, *opts) | |
| 94 | + | fin := p.postJSON("/-/passkeys/register/finish", token, map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)}) | |
| 95 | + | if fin.status == http.StatusNoContent { | |
| 96 | + | p.auth.AddCredential(p.cred) | |
| 97 | + | p.auth.Options.UserHandle = p.userHandle() | |
| 98 | + | } | |
| 99 | + | return fin | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // userHandle reads the owner's WebAuthn user handle, which a real | |
| 103 | + | // authenticator got from the creation options. | |
| 104 | + | func (p *passkeyHarness) userHandle() []byte { | |
| 105 | + | h, err := p.forge.store.Passkeys().UserHandle(p.ctx) | |
| 106 | + | require.NoError(p.t, err) | |
| 107 | + | return h | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // login signs in with the authenticator; it returns the answer of finish. | |
| 111 | + | func (p *passkeyHarness) login() page { | |
| 112 | + | p.t.Helper() | |
| 113 | + | begin := p.postJSON("/-/passkeys/login/begin", "", nil) | |
| 114 | + | require.Equal(p.t, http.StatusOK, begin.status, begin.body) | |
| 115 | + | var br beginReply | |
| 116 | + | require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br)) | |
| 117 | + | opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options)) | |
| 118 | + | require.NoError(p.t, err) | |
| 119 | + | resp := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts) | |
| 120 | + | return p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)}) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // AC1, AC2, AC3: the owner signs in with the CLI link, adds a passkey, signs | |
| 124 | + | // out, signs in with the passkey, sees it in the CLI, and removing it ends | |
| 125 | + | // every session. | |
| 126 | + | func TestPasskeyEndToEnd(t *testing.T) { | |
| 127 | + | p := newPasskeyHarness(t) | |
| 128 | + | p.seed("secret", domain.VisibilityPrivate, "notes.txt") | |
| 129 | + | ||
| 130 | + | // The server announces passkeys, and the pages link to them. | |
| 131 | + | ver := p.cli(t, "", "version", "--json").ok(t) | |
| 132 | + | assert.Contains(t, ver.stdout, `"passkeys"`) | |
| 133 | + | assert.Empty(t, ver.stderr, "no feature is missing") | |
| 134 | + | login := p.b.get("/-/login", nil) | |
| 135 | + | require.Equal(t, http.StatusOK, login.status) | |
| 136 | + | assert.Contains(t, login.body, `data-passkey="login"`) | |
| 137 | + | ||
| 138 | + | // Anonymous: the list is a 404, registration is refused. | |
| 139 | + | assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", nil).status) | |
| 140 | + | assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", map[string]any{}).status) | |
| 141 | + | ||
| 142 | + | // Sign in with the CLI link (the way in before any passkey), add a passkey. | |
| 143 | + | link := p.cli(t, adminToken, "web", "login").ok(t) | |
| 144 | + | pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "") | |
| 145 | + | require.Equal(t, http.StatusSeeOther, pg.status, pg.body) | |
| 146 | + | token := pg.cookie(sessionCookie).Value | |
| 147 | + | ||
| 148 | + | fin := p.register(token, "Test phone") | |
| 149 | + | require.Equal(t, http.StatusNoContent, fin.status, fin.body) | |
| 150 | + | ||
| 151 | + | list := p.b.get("/-/passkeys", as(token)) | |
| 152 | + | require.Equal(t, http.StatusOK, list.status) | |
| 153 | + | assert.Contains(t, list.body, "Test phone") | |
| 154 | + | assert.Equal(t, "private, no-store", list.hdr.Get("Cache-Control")) | |
| 155 | + | ||
| 156 | + | // The same credential cannot be added twice. | |
| 157 | + | dup := p.register(token, "again") | |
| 158 | + | assert.Equal(t, http.StatusConflict, dup.status, dup.body) | |
| 159 | + | ||
| 160 | + | // AC2: an anonymous browser signs in with the passkey: same cookie. | |
| 161 | + | res := p.login() | |
| 162 | + | require.Equal(t, http.StatusNoContent, res.status, res.body) | |
| 163 | + | var ck *http.Cookie | |
| 164 | + | for _, c := range (&http.Response{Header: res.hdr}).Cookies() { | |
| 165 | + | if c.Name == sessionCookie { | |
| 166 | + | ck = c | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | require.NotNil(t, ck) | |
| 170 | + | assert.True(t, ck.Secure) | |
| 171 | + | assert.True(t, ck.HttpOnly) | |
| 172 | + | assert.Equal(t, http.SameSiteStrictMode, ck.SameSite) | |
| 173 | + | assert.Equal(t, 12*3600, ck.MaxAge) | |
| 174 | + | home := p.b.get("/", as(ck.Value)) | |
| 175 | + | assert.Contains(t, home.body, "secret", "the passkey session is the owner's") | |
| 176 | + | ||
| 177 | + | // AC3: the CLI lists it, with its use; the id removes it. | |
| 178 | + | out := p.cli(t, adminToken, "web", "passkey", "list", "--json").ok(t) | |
| 179 | + | var listed []struct { | |
| 180 | + | ID string `json:"id"` | |
| 181 | + | Name string `json:"name"` | |
| 182 | + | LastUsedAt *string `json:"last_used_at"` | |
| 183 | + | Synced bool `json:"synced"` | |
| 184 | + | } | |
| 185 | + | require.NoError(t, json.Unmarshal([]byte(out.stdout), &listed)) | |
| 186 | + | require.Len(t, listed, 1) | |
| 187 | + | assert.Equal(t, "Test phone", listed[0].Name) | |
| 188 | + | assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID) | |
| 189 | + | assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded") | |
| 190 | + | ||
| 191 | + | rm := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID).ok(t) | |
| 192 | + | assert.Contains(t, rm.stderr, "every browser is signed out") | |
| 193 | + | for _, tok := range []string{token, ck.Value} { | |
| 194 | + | assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", as(tok)).status, "the epoch moved: both sessions are gone") | |
| 195 | + | } | |
| 196 | + | // The removed passkey no longer signs in, and is not there to remove again. | |
| 197 | + | res = p.login() | |
| 198 | + | assert.Equal(t, http.StatusUnauthorized, res.status, res.body) | |
| 199 | + | assert.Empty(t, res.hdr.Values("Set-Cookie")) | |
| 200 | + | again := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID) | |
| 201 | + | require.Error(t, again.err) | |
| 202 | + | assert.Contains(t, again.err.Error(), "not found") | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // A passkey that signs in is refused when the origin is wrong, and nothing is set. | |
| 206 | + | func TestPasskeyLoginRefusedFromAnotherOrigin(t *testing.T) { | |
| 207 | + | p := newPasskeyHarness(t) | |
| 208 | + | link := p.cli(t, adminToken, "web", "login").ok(t) | |
| 209 | + | pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "") | |
| 210 | + | token := pg.cookie(sessionCookie).Value | |
| 211 | + | require.Equal(t, http.StatusNoContent, p.register(token, "k").status) | |
| 212 | + | ||
| 213 | + | // The Origin header is wrong: the gate refuses before anything else. | |
| 214 | + | req, err := http.NewRequest("POST", p.url+"/-/passkeys/login/begin", strings.NewReader("")) | |
| 215 | + | require.NoError(t, err) | |
| 216 | + | req.Header.Set("Origin", "https://evil.example") | |
| 217 | + | resp, err := http.DefaultClient.Do(req) | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | _ = resp.Body.Close() | |
| 220 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |
| 221 | + | ||
| 222 | + | // The authenticator answers for another relying party. | |
| 223 | + | begin := p.postJSON("/-/passkeys/login/begin", "", nil) | |
| 224 | + | var br beginReply | |
| 225 | + | require.NoError(t, json.Unmarshal([]byte(begin.body), &br)) | |
| 226 | + | opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options)) | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | evil := virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "x", Origin: "https://evil.example"} | |
| 229 | + | resp2 := virtualwebauthn.CreateAssertionResponse(evil, p.auth, p.cred, *opts) | |
| 230 | + | fin := p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp2)}) | |
| 231 | + | assert.Equal(t, http.StatusUnauthorized, fin.status, fin.body) | |
| 232 | + | assert.Empty(t, fin.hdr.Values("Set-Cookie")) | |
| 233 | + | ||
| 234 | + | // The ceremony is burnt: even the right answer is refused afterwards. | |
| 235 | + | good := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts) | |
| 236 | + | fin = p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(good)}) | |
| 237 | + | assert.Equal(t, http.StatusUnauthorized, fin.status) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // AC4: on the harness's http public URL (not https), passkeys are off: no | |
| 241 | + | // route, no link, no feature, and the RPCs say so. | |
| 242 | + | func TestPasskeysDisabledOnHTTPPublicURL(t *testing.T) { | |
| 243 | + | c := newCLIHarness(t) // http://example.test | |
| 244 | + | b := browser{c.harness} | |
| 245 | + | assert.Equal(t, http.StatusNotFound, b.get("/-/login", nil).status) | |
| 246 | + | assert.Equal(t, http.StatusForbidden, b.do("POST", "/-/passkeys/login/begin", map[string]string{"Origin": publicOrigin}, "").status, "no session, no route") | |
| 247 | + | token := c.signIn(t) | |
| 248 | + | assert.Equal(t, http.StatusNotFound, b.get("/-/passkeys", as(token)).status) | |
| 249 | + | home := b.get("/", as(token)) | |
| 250 | + | assert.NotContains(t, home.body, "/-/passkeys") | |
| 251 | + | assert.NotContains(t, b.get("/", nil).body, `href="/-/login"`) | |
| 252 | + | ||
| 253 | + | res := c.cli(t, adminToken, "web", "passkey", "list") | |
| 254 | + | require.Error(t, res.err) | |
| 255 | + | assert.Contains(t, res.err.Error(), "passkeys are not available on this server") | |
| 256 | + | ver := c.cli(t, "", "version", "--json").ok(t) | |
| 257 | + | assert.NotContains(t, ver.stdout, `"features": [`+"\n"+` "passkeys"`) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // A passkey whose counter does not advance (a hardware key that was cloned) is | |
| 261 | + | // refused end to end, with nothing set and the stored counter kept, while a | |
| 262 | + | // counter that advances signs in. Without the sign-count check of the use case | |
| 263 | + | // the clone would sign in: the WebAuthn adapter accepts it and only reports the | |
| 264 | + | // stored counter back. | |
| 265 | + | func TestPasskeyClonedAuthenticator(t *testing.T) { | |
| 266 | + | p := newPasskeyHarness(t) | |
| 267 | + | link := p.cli(t, adminToken, "web", "login").ok(t) | |
| 268 | + | pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "") | |
| 269 | + | token := pg.cookie(sessionCookie).Value | |
| 270 | + | ||
| 271 | + | // A hardware key: its counter counts up from 1. | |
| 272 | + | p.cred.Counter = 1 | |
| 273 | + | require.Equal(t, http.StatusNoContent, p.register(token, "key").status) | |
| 274 | + | ||
| 275 | + | p.cred.Counter = 5 | |
| 276 | + | res := p.login() | |
| 277 | + | require.Equal(t, http.StatusNoContent, res.status, res.body) | |
| 278 | + | ||
| 279 | + | // The clone presents the counter it last saw: not higher than the stored 5. | |
| 280 | + | for _, counter := range []uint32{5, 3} { | |
| 281 | + | p.cred.Counter = counter | |
| 282 | + | res = p.login() | |
| 283 | + | assert.Equal(t, http.StatusUnauthorized, res.status, "counter %d: %s", counter, res.body) | |
| 284 | + | assert.Empty(t, res.hdr.Values("Set-Cookie"), "no session for a clone") | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // The refusals recorded nothing: the next higher counter still works. | |
| 288 | + | p.cred.Counter = 6 | |
| 289 | + | res = p.login() | |
| 290 | + | assert.Equal(t, http.StatusNoContent, res.status, res.body) | |
| 291 | + | ||
| 292 | + | stored, err := p.forge.store.Passkeys().List(p.ctx) | |
| 293 | + | require.NoError(t, err) | |
| 294 | + | require.Len(t, stored, 1) | |
| 295 | + | assert.Equal(t, uint32(6), stored[0].SignCount) | |
| 296 | + | ||
| 297 | + | // The warning names the passkey by its short reference, never by its id. | |
| 298 | + | sum := sha256.Sum256(p.cred.ID) | |
| 299 | + | ref := hex.EncodeToString(sum[:])[:8] | |
| 300 | + | var warns []string | |
| 301 | + | for _, line := range strings.Split(p.logs.String(), "\n") { | |
| 302 | + | var m map[string]any | |
| 303 | + | if json.Unmarshal([]byte(line), &m) == nil && m["level"] == "warn" && m["passkey"] == ref { | |
| 304 | + | warns = append(warns, line) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | assert.Len(t, warns, 2, "one warning per refused clone") | |
| 308 | + | assert.NotContains(t, p.logs.String(), base64.RawURLEncoding.EncodeToString(p.cred.ID)) | |
| 309 | + | } | |
M internal/app/web_session_test.go +27 -0
| @@ -596,3 +596,30 @@ func TestWarnSharedClientKey(t *testing.T) { | |||
| 596 | 596 | }) | |
| 597 | 597 | } | |
| 598 | 598 | } | |
| 599 | + | ||
| 600 | + | // AC4: passkeys exist only where WebAuthn can work: an https public URL (or | |
| 601 | + | // localhost) with a DNS name as host. | |
| 602 | + | func TestPasskeysAvailable(t *testing.T) { | |
| 603 | + | t.Parallel() | |
| 604 | + | for in, want := range map[string]struct { | |
| 605 | + | ok bool | |
| 606 | + | reason string | |
| 607 | + | }{ | |
| 608 | + | "https://git.example.com": {true, ""}, | |
| 609 | + | "https://git.example.com:8443": {true, ""}, | |
| 610 | + | "https://GIT.example.com/": {true, ""}, | |
| 611 | + | "http://localhost:7341": {true, ""}, | |
| 612 | + | "http://127.0.0.1:7341": {false, "public URL host is an IP address"}, | |
| 613 | + | "http://[::1]:7341": {false, "public URL host is an IP address"}, | |
| 614 | + | "https://203.0.113.7": {false, "public URL host is an IP address"}, | |
| 615 | + | "https://[2001:db8::1]": {false, "public URL host is an IP address"}, | |
| 616 | + | "http://git.example.com": {false, "needs https"}, | |
| 617 | + | "http://192.168.1.2:7341": {false, "needs https"}, | |
| 618 | + | "": {false, "public URL has no host"}, | |
| 619 | + | "https://": {false, "public URL has no host"}, | |
| 620 | + | } { | |
| 621 | + | ok, reason := passkeysAvailable(in) | |
| 622 | + | assert.Equal(t, want.ok, ok, in) | |
| 623 | + | assert.Equal(t, want.reason, reason, in) | |
| 624 | + | } | |
| 625 | + | } | |
A internal/domain/passkey.go +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | // MaxPasskeys is the most passkeys the owner may register. | |
| 12 | + | MaxPasskeys = 20 | |
| 13 | + | // MaxPasskeyIDLen is the longest credential id, in bytes (the WebAuthn | |
| 14 | + | // limit). | |
| 15 | + | MaxPasskeyIDLen = 1023 | |
| 16 | + | // MaxPasskeyNameLen is the longest passkey name, in bytes. | |
| 17 | + | MaxPasskeyNameLen = 64 | |
| 18 | + | // DefaultPasskeyName names a passkey registered without a name. | |
| 19 | + | DefaultPasskeyName = "passkey" | |
| 20 | + | // PasskeyCeremonyTTL is how long a registration or a sign-in may take, from | |
| 21 | + | // its begin to its finish. The WebAuthn adapter enforces it on the | |
| 22 | + | // challenge and the use case on the ceremony it keeps. | |
| 23 | + | PasskeyCeremonyTTL = 2 * time.Minute | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // Passkey is a WebAuthn credential that can sign the owner in. It holds the | |
| 27 | + | // public key only. | |
| 28 | + | type Passkey struct { | |
| 29 | + | // ID is the credential id: at most MaxPasskeyIDLen bytes, never empty. | |
| 30 | + | ID []byte | |
| 31 | + | Name string | |
| 32 | + | // PublicKey is the COSE public key. | |
| 33 | + | PublicKey []byte | |
| 34 | + | AttestationType string | |
| 35 | + | Transports []string | |
| 36 | + | // SignCount is the last counter the authenticator reported; 0 for | |
| 37 | + | // authenticators that have none (synced passkeys). | |
| 38 | + | SignCount uint32 | |
| 39 | + | // BackupEligible and BackupState are the WebAuthn flags: the credential | |
| 40 | + | // can be backed up, and currently is (a synced passkey). | |
| 41 | + | BackupEligible bool | |
| 42 | + | BackupState bool | |
| 43 | + | CreatedAt time.Time | |
| 44 | + | LastUsedAt time.Time // zero until first used | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // PasskeyName is a validated passkey name: one line of plain text, 1 to | |
| 48 | + | // MaxPasskeyNameLen bytes, not blank. Only ParsePasskeyName builds a non-zero | |
| 49 | + | // value. | |
| 50 | + | type PasskeyName struct{ s string } | |
| 51 | + | ||
| 52 | + | // ParsePasskeyName validates s with the rules of the other one-line texts: valid | |
| 53 | + | // UTF-8, no control or bidirectional override character, and not blank. | |
| 54 | + | func ParsePasskeyName(s string) (PasskeyName, error) { | |
| 55 | + | if len(s) > MaxPasskeyNameLen { | |
| 56 | + | return PasskeyName{}, &TextError{Reason: fmt.Sprintf("is longer than %d bytes (%d)", MaxPasskeyNameLen, len(s))} | |
| 57 | + | } | |
| 58 | + | if reason := textProblem(s, false, MaxPasskeyNameLen); reason != "" { | |
| 59 | + | return PasskeyName{}, &TextError{Reason: reason} | |
| 60 | + | } | |
| 61 | + | if isBlank(s) { | |
| 62 | + | return PasskeyName{}, &TextError{Reason: "is empty"} | |
| 63 | + | } | |
| 64 | + | return PasskeyName{s: s}, nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // String returns the name. | |
| 68 | + | func (n PasskeyName) String() string { return n.s } | |
A internal/domain/passkey_test.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestParsePasskeyName(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | in string | |
| 20 | + | reason string // "" when valid | |
| 21 | + | }{ | |
| 22 | + | {"plain", "Pixel 9", ""}, | |
| 23 | + | {"unicode", "clé de ma mère 🔑", ""}, | |
| 24 | + | {"one byte", "a", ""}, | |
| 25 | + | {"max bytes", strings.Repeat("a", domain.MaxPasskeyNameLen), ""}, | |
| 26 | + | {"too long", strings.Repeat("a", domain.MaxPasskeyNameLen+1), "longer than 64"}, | |
| 27 | + | {"too long in bytes, short in characters", strings.Repeat("é", 33), "longer than 64 bytes"}, | |
| 28 | + | {"empty", "", "is empty"}, | |
| 29 | + | {"blank", " \u00a0 ", "is empty"}, | |
| 30 | + | {"line break", "two\nlines", "one line"}, | |
| 31 | + | {"tab", "a\tb", "control character U+0009"}, | |
| 32 | + | {"nul", "a\x00b", "control character U+0000"}, | |
| 33 | + | {"escape", "\x1b[31mred", "control character U+001B"}, | |
| 34 | + | {"bidi override", "evil \u202e txt.exe", "U+202E"}, | |
| 35 | + | {"bidi isolate", "a\u2066b", "U+2066"}, | |
| 36 | + | {"line separator", "a\u2028b", "U+2028"}, | |
| 37 | + | {"invalid utf-8", "a\xffb", "not valid UTF-8"}, | |
| 38 | + | } | |
| 39 | + | for _, tc := range tests { | |
| 40 | + | t.Run(tc.name, func(t *testing.T) { | |
| 41 | + | t.Parallel() | |
| 42 | + | got, err := domain.ParsePasskeyName(tc.in) | |
| 43 | + | if tc.reason == "" { | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | assert.Equal(t, tc.in, got.String()) | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | require.Error(t, err) | |
| 49 | + | assert.ErrorIs(t, err, domain.ErrInvalidText) | |
| 50 | + | assert.True(t, domain.IsInvalid(err)) | |
| 51 | + | assert.Contains(t, err.Error(), tc.reason) | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestDefaultPasskeyNameIsValid(t *testing.T) { | |
| 57 | + | t.Parallel() | |
| 58 | + | _, err := domain.ParsePasskeyName(domain.DefaultPasskeyName) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | } | |
A internal/ports/mocks/PasskeyService.go +179 -0
| @@ -0,0 +1,179 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewPasskeyService creates a new instance of PasskeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewPasskeyService(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *PasskeyService { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &PasskeyService{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // PasskeyService is an autogenerated mock type for the PasskeyService type | |
| 38 | + | type PasskeyService struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type PasskeyService_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *PasskeyService) EXPECT() *PasskeyService_Expecter { | |
| 47 | + | return &PasskeyService_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // List provides a mock function for the type PasskeyService | |
| 51 | + | func (_mock *PasskeyService) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) { | |
| 52 | + | ret := _mock.Called(ctx, p) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for List") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 []domain.Passkey | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.Passkey, error)); ok { | |
| 61 | + | return returnFunc(ctx, p) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.Passkey); ok { | |
| 64 | + | r0 = returnFunc(ctx, p) | |
| 65 | + | } else { | |
| 66 | + | if ret.Get(0) != nil { | |
| 67 | + | r0 = ret.Get(0).([]domain.Passkey) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, p) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // PasskeyService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 79 | + | type PasskeyService_List_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // List is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - p domain.Principal | |
| 86 | + | func (_e *PasskeyService_Expecter) List(ctx any, p any) *PasskeyService_List_Call { | |
| 87 | + | return &PasskeyService_List_Call{Call: _e.mock.On("List", ctx, p)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *PasskeyService_List_Call) Run(run func(ctx context.Context, p domain.Principal)) *PasskeyService_List_Call { | |
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | + | var arg0 context.Context | |
| 93 | + | if args[0] != nil { | |
| 94 | + | arg0 = args[0].(context.Context) | |
| 95 | + | } | |
| 96 | + | var arg1 domain.Principal | |
| 97 | + | if args[1] != nil { | |
| 98 | + | arg1 = args[1].(domain.Principal) | |
| 99 | + | } | |
| 100 | + | run( | |
| 101 | + | arg0, | |
| 102 | + | arg1, | |
| 103 | + | ) | |
| 104 | + | }) | |
| 105 | + | return _c | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (_c *PasskeyService_List_Call) Return(passkeys []domain.Passkey, err error) *PasskeyService_List_Call { | |
| 109 | + | _c.Call.Return(passkeys, err) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *PasskeyService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)) *PasskeyService_List_Call { | |
| 114 | + | _c.Call.Return(run) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Remove provides a mock function for the type PasskeyService | |
| 119 | + | func (_mock *PasskeyService) Remove(ctx context.Context, p domain.Principal, id []byte) error { | |
| 120 | + | ret := _mock.Called(ctx, p, id) | |
| 121 | + | ||
| 122 | + | if len(ret) == 0 { | |
| 123 | + | panic("no return value specified for Remove") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | var r0 error | |
| 127 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, []byte) error); ok { | |
| 128 | + | r0 = returnFunc(ctx, p, id) | |
| 129 | + | } else { | |
| 130 | + | r0 = ret.Error(0) | |
| 131 | + | } | |
| 132 | + | return r0 | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // PasskeyService_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove' | |
| 136 | + | type PasskeyService_Remove_Call struct { | |
| 137 | + | *mock.Call | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Remove is a helper method to define mock.On call | |
| 141 | + | // - ctx context.Context | |
| 142 | + | // - p domain.Principal | |
| 143 | + | // - id []byte | |
| 144 | + | func (_e *PasskeyService_Expecter) Remove(ctx any, p any, id any) *PasskeyService_Remove_Call { | |
| 145 | + | return &PasskeyService_Remove_Call{Call: _e.mock.On("Remove", ctx, p, id)} | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (_c *PasskeyService_Remove_Call) Run(run func(ctx context.Context, p domain.Principal, id []byte)) *PasskeyService_Remove_Call { | |
| 149 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 150 | + | var arg0 context.Context | |
| 151 | + | if args[0] != nil { | |
| 152 | + | arg0 = args[0].(context.Context) | |
| 153 | + | } | |
| 154 | + | var arg1 domain.Principal | |
| 155 | + | if args[1] != nil { | |
| 156 | + | arg1 = args[1].(domain.Principal) | |
| 157 | + | } | |
| 158 | + | var arg2 []byte | |
| 159 | + | if args[2] != nil { | |
| 160 | + | arg2 = args[2].([]byte) | |
| 161 | + | } | |
| 162 | + | run( | |
| 163 | + | arg0, | |
| 164 | + | arg1, | |
| 165 | + | arg2, | |
| 166 | + | ) | |
| 167 | + | }) | |
| 168 | + | return _c | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (_c *PasskeyService_Remove_Call) Return(err error) *PasskeyService_Remove_Call { | |
| 172 | + | _c.Call.Return(err) | |
| 173 | + | return _c | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (_c *PasskeyService_Remove_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id []byte) error) *PasskeyService_Remove_Call { | |
| 177 | + | _c.Call.Return(run) | |
| 178 | + | return _c | |
| 179 | + | } | |
A internal/ports/mocks/PasskeyStore.go +409 -0
| @@ -0,0 +1,409 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewPasskeyStore creates a new instance of PasskeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewPasskeyStore(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *PasskeyStore { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &PasskeyStore{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // PasskeyStore is an autogenerated mock type for the PasskeyStore type | |
| 38 | + | type PasskeyStore struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type PasskeyStore_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *PasskeyStore) EXPECT() *PasskeyStore_Expecter { | |
| 47 | + | return &PasskeyStore_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Create provides a mock function for the type PasskeyStore | |
| 51 | + | func (_mock *PasskeyStore) Create(ctx context.Context, p domain.Passkey) error { | |
| 52 | + | ret := _mock.Called(ctx, p) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Create") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Passkey) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, p) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // PasskeyStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' | |
| 68 | + | type PasskeyStore_Create_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Create is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - p domain.Passkey | |
| 75 | + | func (_e *PasskeyStore_Expecter) Create(ctx any, p any) *PasskeyStore_Create_Call { | |
| 76 | + | return &PasskeyStore_Create_Call{Call: _e.mock.On("Create", ctx, p)} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *PasskeyStore_Create_Call) Run(run func(ctx context.Context, p domain.Passkey)) *PasskeyStore_Create_Call { | |
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | + | var arg0 context.Context | |
| 82 | + | if args[0] != nil { | |
| 83 | + | arg0 = args[0].(context.Context) | |
| 84 | + | } | |
| 85 | + | var arg1 domain.Passkey | |
| 86 | + | if args[1] != nil { | |
| 87 | + | arg1 = args[1].(domain.Passkey) | |
| 88 | + | } | |
| 89 | + | run( | |
| 90 | + | arg0, | |
| 91 | + | arg1, | |
| 92 | + | ) | |
| 93 | + | }) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *PasskeyStore_Create_Call) Return(err error) *PasskeyStore_Create_Call { | |
| 98 | + | _c.Call.Return(err) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *PasskeyStore_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Passkey) error) *PasskeyStore_Create_Call { | |
| 103 | + | _c.Call.Return(run) | |
| 104 | + | return _c | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Delete provides a mock function for the type PasskeyStore | |
| 108 | + | func (_mock *PasskeyStore) Delete(ctx context.Context, id []byte) error { | |
| 109 | + | ret := _mock.Called(ctx, id) | |
| 110 | + | ||
| 111 | + | if len(ret) == 0 { | |
| 112 | + | panic("no return value specified for Delete") | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | var r0 error | |
| 116 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) error); ok { | |
| 117 | + | r0 = returnFunc(ctx, id) | |
| 118 | + | } else { | |
| 119 | + | r0 = ret.Error(0) | |
| 120 | + | } | |
| 121 | + | return r0 | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // PasskeyStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 125 | + | type PasskeyStore_Delete_Call struct { | |
| 126 | + | *mock.Call | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // Delete is a helper method to define mock.On call | |
| 130 | + | // - ctx context.Context | |
| 131 | + | // - id []byte | |
| 132 | + | func (_e *PasskeyStore_Expecter) Delete(ctx any, id any) *PasskeyStore_Delete_Call { | |
| 133 | + | return &PasskeyStore_Delete_Call{Call: _e.mock.On("Delete", ctx, id)} | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (_c *PasskeyStore_Delete_Call) Run(run func(ctx context.Context, id []byte)) *PasskeyStore_Delete_Call { | |
| 137 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 138 | + | var arg0 context.Context | |
| 139 | + | if args[0] != nil { | |
| 140 | + | arg0 = args[0].(context.Context) | |
| 141 | + | } | |
| 142 | + | var arg1 []byte | |
| 143 | + | if args[1] != nil { | |
| 144 | + | arg1 = args[1].([]byte) | |
| 145 | + | } | |
| 146 | + | run( | |
| 147 | + | arg0, | |
| 148 | + | arg1, | |
| 149 | + | ) | |
| 150 | + | }) | |
| 151 | + | return _c | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (_c *PasskeyStore_Delete_Call) Return(err error) *PasskeyStore_Delete_Call { | |
| 155 | + | _c.Call.Return(err) | |
| 156 | + | return _c | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (_c *PasskeyStore_Delete_Call) RunAndReturn(run func(ctx context.Context, id []byte) error) *PasskeyStore_Delete_Call { | |
| 160 | + | _c.Call.Return(run) | |
| 161 | + | return _c | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Get provides a mock function for the type PasskeyStore | |
| 165 | + | func (_mock *PasskeyStore) Get(ctx context.Context, id []byte) (domain.Passkey, error) { | |
| 166 | + | ret := _mock.Called(ctx, id) | |
| 167 | + | ||
| 168 | + | if len(ret) == 0 { | |
| 169 | + | panic("no return value specified for Get") | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | var r0 domain.Passkey | |
| 173 | + | var r1 error | |
| 174 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) (domain.Passkey, error)); ok { | |
| 175 | + | return returnFunc(ctx, id) | |
| 176 | + | } | |
| 177 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) domain.Passkey); ok { | |
| 178 | + | r0 = returnFunc(ctx, id) | |
| 179 | + | } else { | |
| 180 | + | r0 = ret.Get(0).(domain.Passkey) | |
| 181 | + | } | |
| 182 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, []byte) error); ok { | |
| 183 | + | r1 = returnFunc(ctx, id) | |
| 184 | + | } else { | |
| 185 | + | r1 = ret.Error(1) | |
| 186 | + | } | |
| 187 | + | return r0, r1 | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // PasskeyStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |
| 191 | + | type PasskeyStore_Get_Call struct { | |
| 192 | + | *mock.Call | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // Get is a helper method to define mock.On call | |
| 196 | + | // - ctx context.Context | |
| 197 | + | // - id []byte | |
| 198 | + | func (_e *PasskeyStore_Expecter) Get(ctx any, id any) *PasskeyStore_Get_Call { | |
| 199 | + | return &PasskeyStore_Get_Call{Call: _e.mock.On("Get", ctx, id)} | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (_c *PasskeyStore_Get_Call) Run(run func(ctx context.Context, id []byte)) *PasskeyStore_Get_Call { | |
| 203 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 204 | + | var arg0 context.Context | |
| 205 | + | if args[0] != nil { | |
| 206 | + | arg0 = args[0].(context.Context) | |
| 207 | + | } | |
| 208 | + | var arg1 []byte | |
| 209 | + | if args[1] != nil { | |
| 210 | + | arg1 = args[1].([]byte) | |
| 211 | + | } | |
| 212 | + | run( | |
| 213 | + | arg0, | |
| 214 | + | arg1, | |
| 215 | + | ) | |
| 216 | + | }) | |
| 217 | + | return _c | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (_c *PasskeyStore_Get_Call) Return(passkey domain.Passkey, err error) *PasskeyStore_Get_Call { | |
| 221 | + | _c.Call.Return(passkey, err) | |
| 222 | + | return _c | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func (_c *PasskeyStore_Get_Call) RunAndReturn(run func(ctx context.Context, id []byte) (domain.Passkey, error)) *PasskeyStore_Get_Call { | |
| 226 | + | _c.Call.Return(run) | |
| 227 | + | return _c | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // List provides a mock function for the type PasskeyStore | |
| 231 | + | func (_mock *PasskeyStore) List(ctx context.Context) ([]domain.Passkey, error) { | |
| 232 | + | ret := _mock.Called(ctx) | |
| 233 | + | ||
| 234 | + | if len(ret) == 0 { | |
| 235 | + | panic("no return value specified for List") | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | var r0 []domain.Passkey | |
| 239 | + | var r1 error | |
| 240 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Passkey, error)); ok { | |
| 241 | + | return returnFunc(ctx) | |
| 242 | + | } | |
| 243 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Passkey); ok { | |
| 244 | + | r0 = returnFunc(ctx) | |
| 245 | + | } else { | |
| 246 | + | if ret.Get(0) != nil { | |
| 247 | + | r0 = ret.Get(0).([]domain.Passkey) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 251 | + | r1 = returnFunc(ctx) | |
| 252 | + | } else { | |
| 253 | + | r1 = ret.Error(1) | |
| 254 | + | } | |
| 255 | + | return r0, r1 | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // PasskeyStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 259 | + | type PasskeyStore_List_Call struct { | |
| 260 | + | *mock.Call | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // List is a helper method to define mock.On call | |
| 264 | + | // - ctx context.Context | |
| 265 | + | func (_e *PasskeyStore_Expecter) List(ctx any) *PasskeyStore_List_Call { | |
| 266 | + | return &PasskeyStore_List_Call{Call: _e.mock.On("List", ctx)} | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (_c *PasskeyStore_List_Call) Run(run func(ctx context.Context)) *PasskeyStore_List_Call { | |
| 270 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 271 | + | var arg0 context.Context | |
| 272 | + | if args[0] != nil { | |
| 273 | + | arg0 = args[0].(context.Context) | |
| 274 | + | } | |
| 275 | + | run( | |
| 276 | + | arg0, | |
| 277 | + | ) | |
| 278 | + | }) | |
| 279 | + | return _c | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func (_c *PasskeyStore_List_Call) Return(passkeys []domain.Passkey, err error) *PasskeyStore_List_Call { | |
| 283 | + | _c.Call.Return(passkeys, err) | |
| 284 | + | return _c | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (_c *PasskeyStore_List_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Passkey, error)) *PasskeyStore_List_Call { | |
| 288 | + | _c.Call.Return(run) | |
| 289 | + | return _c | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // Update provides a mock function for the type PasskeyStore | |
| 293 | + | func (_mock *PasskeyStore) Update(ctx context.Context, p domain.Passkey) error { | |
| 294 | + | ret := _mock.Called(ctx, p) | |
| 295 | + | ||
| 296 | + | if len(ret) == 0 { | |
| 297 | + | panic("no return value specified for Update") | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | var r0 error | |
| 301 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Passkey) error); ok { | |
| 302 | + | r0 = returnFunc(ctx, p) | |
| 303 | + | } else { | |
| 304 | + | r0 = ret.Error(0) | |
| 305 | + | } | |
| 306 | + | return r0 | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // PasskeyStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' | |
| 310 | + | type PasskeyStore_Update_Call struct { | |
| 311 | + | *mock.Call | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // Update is a helper method to define mock.On call | |
| 315 | + | // - ctx context.Context | |
| 316 | + | // - p domain.Passkey | |
| 317 | + | func (_e *PasskeyStore_Expecter) Update(ctx any, p any) *PasskeyStore_Update_Call { | |
| 318 | + | return &PasskeyStore_Update_Call{Call: _e.mock.On("Update", ctx, p)} | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func (_c *PasskeyStore_Update_Call) Run(run func(ctx context.Context, p domain.Passkey)) *PasskeyStore_Update_Call { | |
| 322 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 323 | + | var arg0 context.Context | |
| 324 | + | if args[0] != nil { | |
| 325 | + | arg0 = args[0].(context.Context) | |
| 326 | + | } | |
| 327 | + | var arg1 domain.Passkey | |
| 328 | + | if args[1] != nil { | |
| 329 | + | arg1 = args[1].(domain.Passkey) | |
| 330 | + | } | |
| 331 | + | run( | |
| 332 | + | arg0, | |
| 333 | + | arg1, | |
| 334 | + | ) | |
| 335 | + | }) | |
| 336 | + | return _c | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func (_c *PasskeyStore_Update_Call) Return(err error) *PasskeyStore_Update_Call { | |
| 340 | + | _c.Call.Return(err) | |
| 341 | + | return _c | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func (_c *PasskeyStore_Update_Call) RunAndReturn(run func(ctx context.Context, p domain.Passkey) error) *PasskeyStore_Update_Call { | |
| 345 | + | _c.Call.Return(run) | |
| 346 | + | return _c | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | // UserHandle provides a mock function for the type PasskeyStore | |
| 350 | + | func (_mock *PasskeyStore) UserHandle(ctx context.Context) ([]byte, error) { | |
| 351 | + | ret := _mock.Called(ctx) | |
| 352 | + | ||
| 353 | + | if len(ret) == 0 { | |
| 354 | + | panic("no return value specified for UserHandle") | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | var r0 []byte | |
| 358 | + | var r1 error | |
| 359 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]byte, error)); ok { | |
| 360 | + | return returnFunc(ctx) | |
| 361 | + | } | |
| 362 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []byte); ok { | |
| 363 | + | r0 = returnFunc(ctx) | |
| 364 | + | } else { | |
| 365 | + | if ret.Get(0) != nil { | |
| 366 | + | r0 = ret.Get(0).([]byte) | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 370 | + | r1 = returnFunc(ctx) | |
| 371 | + | } else { | |
| 372 | + | r1 = ret.Error(1) | |
| 373 | + | } | |
| 374 | + | return r0, r1 | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | // PasskeyStore_UserHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserHandle' | |
| 378 | + | type PasskeyStore_UserHandle_Call struct { | |
| 379 | + | *mock.Call | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // UserHandle is a helper method to define mock.On call | |
| 383 | + | // - ctx context.Context | |
| 384 | + | func (_e *PasskeyStore_Expecter) UserHandle(ctx any) *PasskeyStore_UserHandle_Call { | |
| 385 | + | return &PasskeyStore_UserHandle_Call{Call: _e.mock.On("UserHandle", ctx)} | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func (_c *PasskeyStore_UserHandle_Call) Run(run func(ctx context.Context)) *PasskeyStore_UserHandle_Call { | |
| 389 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 390 | + | var arg0 context.Context | |
| 391 | + | if args[0] != nil { | |
| 392 | + | arg0 = args[0].(context.Context) | |
| 393 | + | } | |
| 394 | + | run( | |
| 395 | + | arg0, | |
| 396 | + | ) | |
| 397 | + | }) | |
| 398 | + | return _c | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func (_c *PasskeyStore_UserHandle_Call) Return(bytes []byte, err error) *PasskeyStore_UserHandle_Call { | |
| 402 | + | _c.Call.Return(bytes, err) | |
| 403 | + | return _c | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func (_c *PasskeyStore_UserHandle_Call) RunAndReturn(run func(ctx context.Context) ([]byte, error)) *PasskeyStore_UserHandle_Call { | |
| 407 | + | _c.Call.Return(run) | |
| 408 | + | return _c | |
| 409 | + | } | |
A internal/ports/mocks/PasskeyVerifier.go +335 -0
| @@ -0,0 +1,335 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "bnema.dev/ermite/internal/domain" | |
| 9 | + | mock "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NewPasskeyVerifier creates a new instance of PasskeyVerifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | + | // The first argument is typically a *testing.T value. | |
| 14 | + | func NewPasskeyVerifier(t interface { | |
| 15 | + | mock.TestingT | |
| 16 | + | Cleanup(func()) | |
| 17 | + | }) *PasskeyVerifier { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | mock := &PasskeyVerifier{} | |
| 23 | + | mock.Mock.Test(t) | |
| 24 | + | ||
| 25 | + | t.Cleanup(func() { | |
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 27 | + | helper.Helper() | |
| 28 | + | } | |
| 29 | + | mock.AssertExpectations(t) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | return mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // PasskeyVerifier is an autogenerated mock type for the PasskeyVerifier type | |
| 36 | + | type PasskeyVerifier struct { | |
| 37 | + | mock.Mock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type PasskeyVerifier_Expecter struct { | |
| 41 | + | mock *mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (_m *PasskeyVerifier) EXPECT() *PasskeyVerifier_Expecter { | |
| 45 | + | return &PasskeyVerifier_Expecter{mock: &_m.Mock} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // BeginLogin provides a mock function for the type PasskeyVerifier | |
| 49 | + | func (_mock *PasskeyVerifier) BeginLogin() ([]byte, []byte, error) { | |
| 50 | + | ret := _mock.Called() | |
| 51 | + | ||
| 52 | + | if len(ret) == 0 { | |
| 53 | + | panic("no return value specified for BeginLogin") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | var r0 []byte | |
| 57 | + | var r1 []byte | |
| 58 | + | var r2 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func() ([]byte, []byte, error)); ok { | |
| 60 | + | return returnFunc() | |
| 61 | + | } | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func() []byte); ok { | |
| 63 | + | r0 = returnFunc() | |
| 64 | + | } else { | |
| 65 | + | if ret.Get(0) != nil { | |
| 66 | + | r0 = ret.Get(0).([]byte) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func() []byte); ok { | |
| 70 | + | r1 = returnFunc() | |
| 71 | + | } else { | |
| 72 | + | if ret.Get(1) != nil { | |
| 73 | + | r1 = ret.Get(1).([]byte) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | if returnFunc, ok := ret.Get(2).(func() error); ok { | |
| 77 | + | r2 = returnFunc() | |
| 78 | + | } else { | |
| 79 | + | r2 = ret.Error(2) | |
| 80 | + | } | |
| 81 | + | return r0, r1, r2 | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // PasskeyVerifier_BeginLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginLogin' | |
| 85 | + | type PasskeyVerifier_BeginLogin_Call struct { | |
| 86 | + | *mock.Call | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // BeginLogin is a helper method to define mock.On call | |
| 90 | + | func (_e *PasskeyVerifier_Expecter) BeginLogin() *PasskeyVerifier_BeginLogin_Call { | |
| 91 | + | return &PasskeyVerifier_BeginLogin_Call{Call: _e.mock.On("BeginLogin")} | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (_c *PasskeyVerifier_BeginLogin_Call) Run(run func()) *PasskeyVerifier_BeginLogin_Call { | |
| 95 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 96 | + | run() | |
| 97 | + | }) | |
| 98 | + | return _c | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (_c *PasskeyVerifier_BeginLogin_Call) Return(options []byte, ceremony []byte, err error) *PasskeyVerifier_BeginLogin_Call { | |
| 102 | + | _c.Call.Return(options, ceremony, err) | |
| 103 | + | return _c | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (_c *PasskeyVerifier_BeginLogin_Call) RunAndReturn(run func() ([]byte, []byte, error)) *PasskeyVerifier_BeginLogin_Call { | |
| 107 | + | _c.Call.Return(run) | |
| 108 | + | return _c | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // BeginRegistration provides a mock function for the type PasskeyVerifier | |
| 112 | + | func (_mock *PasskeyVerifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) { | |
| 113 | + | ret := _mock.Called(userHandle, existing) | |
| 114 | + | ||
| 115 | + | if len(ret) == 0 { | |
| 116 | + | panic("no return value specified for BeginRegistration") | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | var r0 []byte | |
| 120 | + | var r1 []byte | |
| 121 | + | var r2 error | |
| 122 | + | if returnFunc, ok := ret.Get(0).(func([]byte, []domain.Passkey) ([]byte, []byte, error)); ok { | |
| 123 | + | return returnFunc(userHandle, existing) | |
| 124 | + | } | |
| 125 | + | if returnFunc, ok := ret.Get(0).(func([]byte, []domain.Passkey) []byte); ok { | |
| 126 | + | r0 = returnFunc(userHandle, existing) | |
| 127 | + | } else { | |
| 128 | + | if ret.Get(0) != nil { | |
| 129 | + | r0 = ret.Get(0).([]byte) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | if returnFunc, ok := ret.Get(1).(func([]byte, []domain.Passkey) []byte); ok { | |
| 133 | + | r1 = returnFunc(userHandle, existing) | |
| 134 | + | } else { | |
| 135 | + | if ret.Get(1) != nil { | |
| 136 | + | r1 = ret.Get(1).([]byte) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | if returnFunc, ok := ret.Get(2).(func([]byte, []domain.Passkey) error); ok { | |
| 140 | + | r2 = returnFunc(userHandle, existing) | |
| 141 | + | } else { | |
| 142 | + | r2 = ret.Error(2) | |
| 143 | + | } | |
| 144 | + | return r0, r1, r2 | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // PasskeyVerifier_BeginRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginRegistration' | |
| 148 | + | type PasskeyVerifier_BeginRegistration_Call struct { | |
| 149 | + | *mock.Call | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // BeginRegistration is a helper method to define mock.On call | |
| 153 | + | // - userHandle []byte | |
| 154 | + | // - existing []domain.Passkey | |
| 155 | + | func (_e *PasskeyVerifier_Expecter) BeginRegistration(userHandle any, existing any) *PasskeyVerifier_BeginRegistration_Call { | |
| 156 | + | return &PasskeyVerifier_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", userHandle, existing)} | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (_c *PasskeyVerifier_BeginRegistration_Call) Run(run func(userHandle []byte, existing []domain.Passkey)) *PasskeyVerifier_BeginRegistration_Call { | |
| 160 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 161 | + | var arg0 []byte | |
| 162 | + | if args[0] != nil { | |
| 163 | + | arg0 = args[0].([]byte) | |
| 164 | + | } | |
| 165 | + | var arg1 []domain.Passkey | |
| 166 | + | if args[1] != nil { | |
| 167 | + | arg1 = args[1].([]domain.Passkey) | |
| 168 | + | } | |
| 169 | + | run( | |
| 170 | + | arg0, | |
| 171 | + | arg1, | |
| 172 | + | ) | |
| 173 | + | }) | |
| 174 | + | return _c | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (_c *PasskeyVerifier_BeginRegistration_Call) Return(options []byte, ceremony []byte, err error) *PasskeyVerifier_BeginRegistration_Call { | |
| 178 | + | _c.Call.Return(options, ceremony, err) | |
| 179 | + | return _c | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (_c *PasskeyVerifier_BeginRegistration_Call) RunAndReturn(run func(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error)) *PasskeyVerifier_BeginRegistration_Call { | |
| 183 | + | _c.Call.Return(run) | |
| 184 | + | return _c | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // FinishLogin provides a mock function for the type PasskeyVerifier | |
| 188 | + | func (_mock *PasskeyVerifier) FinishLogin(userHandle []byte, ceremony []byte, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 189 | + | ret := _mock.Called(userHandle, ceremony, response, lookup) | |
| 190 | + | ||
| 191 | + | if len(ret) == 0 { | |
| 192 | + | panic("no return value specified for FinishLogin") | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | var r0 domain.Passkey | |
| 196 | + | var r1 error | |
| 197 | + | if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte, func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)); ok { | |
| 198 | + | return returnFunc(userHandle, ceremony, response, lookup) | |
| 199 | + | } | |
| 200 | + | if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte, func(id []byte) (domain.Passkey, error)) domain.Passkey); ok { | |
| 201 | + | r0 = returnFunc(userHandle, ceremony, response, lookup) | |
| 202 | + | } else { | |
| 203 | + | r0 = ret.Get(0).(domain.Passkey) | |
| 204 | + | } | |
| 205 | + | if returnFunc, ok := ret.Get(1).(func([]byte, []byte, []byte, func(id []byte) (domain.Passkey, error)) error); ok { | |
| 206 | + | r1 = returnFunc(userHandle, ceremony, response, lookup) | |
| 207 | + | } else { | |
| 208 | + | r1 = ret.Error(1) | |
| 209 | + | } | |
| 210 | + | return r0, r1 | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // PasskeyVerifier_FinishLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishLogin' | |
| 214 | + | type PasskeyVerifier_FinishLogin_Call struct { | |
| 215 | + | *mock.Call | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // FinishLogin is a helper method to define mock.On call | |
| 219 | + | // - userHandle []byte | |
| 220 | + | // - ceremony []byte | |
| 221 | + | // - response []byte | |
| 222 | + | // - lookup func(id []byte) (domain.Passkey, error) | |
| 223 | + | func (_e *PasskeyVerifier_Expecter) FinishLogin(userHandle any, ceremony any, response any, lookup any) *PasskeyVerifier_FinishLogin_Call { | |
| 224 | + | return &PasskeyVerifier_FinishLogin_Call{Call: _e.mock.On("FinishLogin", userHandle, ceremony, response, lookup)} | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (_c *PasskeyVerifier_FinishLogin_Call) Run(run func(userHandle []byte, ceremony []byte, response []byte, lookup func(id []byte) (domain.Passkey, error))) *PasskeyVerifier_FinishLogin_Call { | |
| 228 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 229 | + | var arg0 []byte | |
| 230 | + | if args[0] != nil { | |
| 231 | + | arg0 = args[0].([]byte) | |
| 232 | + | } | |
| 233 | + | var arg1 []byte | |
| 234 | + | if args[1] != nil { | |
| 235 | + | arg1 = args[1].([]byte) | |
| 236 | + | } | |
| 237 | + | var arg2 []byte | |
| 238 | + | if args[2] != nil { | |
| 239 | + | arg2 = args[2].([]byte) | |
| 240 | + | } | |
| 241 | + | var arg3 func(id []byte) (domain.Passkey, error) | |
| 242 | + | if args[3] != nil { | |
| 243 | + | arg3 = args[3].(func(id []byte) (domain.Passkey, error)) | |
| 244 | + | } | |
| 245 | + | run( | |
| 246 | + | arg0, | |
| 247 | + | arg1, | |
| 248 | + | arg2, | |
| 249 | + | arg3, | |
| 250 | + | ) | |
| 251 | + | }) | |
| 252 | + | return _c | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func (_c *PasskeyVerifier_FinishLogin_Call) Return(passkey domain.Passkey, err error) *PasskeyVerifier_FinishLogin_Call { | |
| 256 | + | _c.Call.Return(passkey, err) | |
| 257 | + | return _c | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (_c *PasskeyVerifier_FinishLogin_Call) RunAndReturn(run func(userHandle []byte, ceremony []byte, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)) *PasskeyVerifier_FinishLogin_Call { | |
| 261 | + | _c.Call.Return(run) | |
| 262 | + | return _c | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // FinishRegistration provides a mock function for the type PasskeyVerifier | |
| 266 | + | func (_mock *PasskeyVerifier) FinishRegistration(userHandle []byte, ceremony []byte, response []byte) (domain.Passkey, error) { | |
| 267 | + | ret := _mock.Called(userHandle, ceremony, response) | |
| 268 | + | ||
| 269 | + | if len(ret) == 0 { | |
| 270 | + | panic("no return value specified for FinishRegistration") | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | var r0 domain.Passkey | |
| 274 | + | var r1 error | |
| 275 | + | if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte) (domain.Passkey, error)); ok { | |
| 276 | + | return returnFunc(userHandle, ceremony, response) | |
| 277 | + | } | |
| 278 | + | if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte) domain.Passkey); ok { | |
| 279 | + | r0 = returnFunc(userHandle, ceremony, response) | |
| 280 | + | } else { | |
| 281 | + | r0 = ret.Get(0).(domain.Passkey) | |
| 282 | + | } | |
| 283 | + | if returnFunc, ok := ret.Get(1).(func([]byte, []byte, []byte) error); ok { | |
| 284 | + | r1 = returnFunc(userHandle, ceremony, response) | |
| 285 | + | } else { | |
| 286 | + | r1 = ret.Error(1) | |
| 287 | + | } | |
| 288 | + | return r0, r1 | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // PasskeyVerifier_FinishRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishRegistration' | |
| 292 | + | type PasskeyVerifier_FinishRegistration_Call struct { | |
| 293 | + | *mock.Call | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // FinishRegistration is a helper method to define mock.On call | |
| 297 | + | // - userHandle []byte | |
| 298 | + | // - ceremony []byte | |
| 299 | + | // - response []byte | |
| 300 | + | func (_e *PasskeyVerifier_Expecter) FinishRegistration(userHandle any, ceremony any, response any) *PasskeyVerifier_FinishRegistration_Call { | |
| 301 | + | return &PasskeyVerifier_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", userHandle, ceremony, response)} | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func (_c *PasskeyVerifier_FinishRegistration_Call) Run(run func(userHandle []byte, ceremony []byte, response []byte)) *PasskeyVerifier_FinishRegistration_Call { | |
| 305 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 306 | + | var arg0 []byte | |
| 307 | + | if args[0] != nil { | |
| 308 | + | arg0 = args[0].([]byte) | |
| 309 | + | } | |
| 310 | + | var arg1 []byte | |
| 311 | + | if args[1] != nil { | |
| 312 | + | arg1 = args[1].([]byte) | |
| 313 | + | } | |
| 314 | + | var arg2 []byte | |
| 315 | + | if args[2] != nil { | |
| 316 | + | arg2 = args[2].([]byte) | |
| 317 | + | } | |
| 318 | + | run( | |
| 319 | + | arg0, | |
| 320 | + | arg1, | |
| 321 | + | arg2, | |
| 322 | + | ) | |
| 323 | + | }) | |
| 324 | + | return _c | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func (_c *PasskeyVerifier_FinishRegistration_Call) Return(passkey domain.Passkey, err error) *PasskeyVerifier_FinishRegistration_Call { | |
| 328 | + | _c.Call.Return(passkey, err) | |
| 329 | + | return _c | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (_c *PasskeyVerifier_FinishRegistration_Call) RunAndReturn(run func(userHandle []byte, ceremony []byte, response []byte) (domain.Passkey, error)) *PasskeyVerifier_FinishRegistration_Call { | |
| 333 | + | _c.Call.Return(run) | |
| 334 | + | return _c | |
| 335 | + | } | |
A internal/ports/mocks/WebPasskeyService.go +481 -0
| @@ -0,0 +1,481 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewWebPasskeyService creates a new instance of WebPasskeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewWebPasskeyService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *WebPasskeyService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &WebPasskeyService{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // WebPasskeyService is an autogenerated mock type for the WebPasskeyService type | |
| 39 | + | type WebPasskeyService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type WebPasskeyService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *WebPasskeyService) EXPECT() *WebPasskeyService_Expecter { | |
| 48 | + | return &WebPasskeyService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // BeginLogin provides a mock function for the type WebPasskeyService | |
| 52 | + | func (_mock *WebPasskeyService) BeginLogin(ctx context.Context, client string) (string, []byte, error) { | |
| 53 | + | ret := _mock.Called(ctx, client) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for BeginLogin") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 string | |
| 60 | + | var r1 []byte | |
| 61 | + | var r2 error | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, []byte, error)); ok { | |
| 63 | + | return returnFunc(ctx, client) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |
| 66 | + | r0 = returnFunc(ctx, client) | |
| 67 | + | } else { | |
| 68 | + | r0 = ret.Get(0).(string) | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) []byte); ok { | |
| 71 | + | r1 = returnFunc(ctx, client) | |
| 72 | + | } else { | |
| 73 | + | if ret.Get(1) != nil { | |
| 74 | + | r1 = ret.Get(1).([]byte) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |
| 78 | + | r2 = returnFunc(ctx, client) | |
| 79 | + | } else { | |
| 80 | + | r2 = ret.Error(2) | |
| 81 | + | } | |
| 82 | + | return r0, r1, r2 | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // WebPasskeyService_BeginLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginLogin' | |
| 86 | + | type WebPasskeyService_BeginLogin_Call struct { | |
| 87 | + | *mock.Call | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // BeginLogin is a helper method to define mock.On call | |
| 91 | + | // - ctx context.Context | |
| 92 | + | // - client string | |
| 93 | + | func (_e *WebPasskeyService_Expecter) BeginLogin(ctx any, client any) *WebPasskeyService_BeginLogin_Call { | |
| 94 | + | return &WebPasskeyService_BeginLogin_Call{Call: _e.mock.On("BeginLogin", ctx, client)} | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *WebPasskeyService_BeginLogin_Call) Run(run func(ctx context.Context, client string)) *WebPasskeyService_BeginLogin_Call { | |
| 98 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 99 | + | var arg0 context.Context | |
| 100 | + | if args[0] != nil { | |
| 101 | + | arg0 = args[0].(context.Context) | |
| 102 | + | } | |
| 103 | + | var arg1 string | |
| 104 | + | if args[1] != nil { | |
| 105 | + | arg1 = args[1].(string) | |
| 106 | + | } | |
| 107 | + | run( | |
| 108 | + | arg0, | |
| 109 | + | arg1, | |
| 110 | + | ) | |
| 111 | + | }) | |
| 112 | + | return _c | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (_c *WebPasskeyService_BeginLogin_Call) Return(ceremony string, options []byte, err error) *WebPasskeyService_BeginLogin_Call { | |
| 116 | + | _c.Call.Return(ceremony, options, err) | |
| 117 | + | return _c | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (_c *WebPasskeyService_BeginLogin_Call) RunAndReturn(run func(ctx context.Context, client string) (string, []byte, error)) *WebPasskeyService_BeginLogin_Call { | |
| 121 | + | _c.Call.Return(run) | |
| 122 | + | return _c | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // BeginRegistration provides a mock function for the type WebPasskeyService | |
| 126 | + | func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.Principal, name string) (string, []byte, error) { | |
| 127 | + | ret := _mock.Called(ctx, p, name) | |
| 128 | + | ||
| 129 | + | if len(ret) == 0 { | |
| 130 | + | panic("no return value specified for BeginRegistration") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | var r0 string | |
| 134 | + | var r1 []byte | |
| 135 | + | var r2 error | |
| 136 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) (string, []byte, error)); ok { | |
| 137 | + | return returnFunc(ctx, p, name) | |
| 138 | + | } | |
| 139 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) string); ok { | |
| 140 | + | r0 = returnFunc(ctx, p, name) | |
| 141 | + | } else { | |
| 142 | + | r0 = ret.Get(0).(string) | |
| 143 | + | } | |
| 144 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string) []byte); ok { | |
| 145 | + | r1 = returnFunc(ctx, p, name) | |
| 146 | + | } else { | |
| 147 | + | if ret.Get(1) != nil { | |
| 148 | + | r1 = ret.Get(1).([]byte) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string) error); ok { | |
| 152 | + | r2 = returnFunc(ctx, p, name) | |
| 153 | + | } else { | |
| 154 | + | r2 = ret.Error(2) | |
| 155 | + | } | |
| 156 | + | return r0, r1, r2 | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // WebPasskeyService_BeginRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginRegistration' | |
| 160 | + | type WebPasskeyService_BeginRegistration_Call struct { | |
| 161 | + | *mock.Call | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // BeginRegistration is a helper method to define mock.On call | |
| 165 | + | // - ctx context.Context | |
| 166 | + | // - p domain.Principal | |
| 167 | + | // - name string | |
| 168 | + | func (_e *WebPasskeyService_Expecter) BeginRegistration(ctx any, p any, name any) *WebPasskeyService_BeginRegistration_Call { | |
| 169 | + | return &WebPasskeyService_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, p, name)} | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Context, p domain.Principal, name string)) *WebPasskeyService_BeginRegistration_Call { | |
| 173 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 174 | + | var arg0 context.Context | |
| 175 | + | if args[0] != nil { | |
| 176 | + | arg0 = args[0].(context.Context) | |
| 177 | + | } | |
| 178 | + | var arg1 domain.Principal | |
| 179 | + | if args[1] != nil { | |
| 180 | + | arg1 = args[1].(domain.Principal) | |
| 181 | + | } | |
| 182 | + | var arg2 string | |
| 183 | + | if args[2] != nil { | |
| 184 | + | arg2 = args[2].(string) | |
| 185 | + | } | |
| 186 | + | run( | |
| 187 | + | arg0, | |
| 188 | + | arg1, | |
| 189 | + | arg2, | |
| 190 | + | ) | |
| 191 | + | }) | |
| 192 | + | return _c | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func (_c *WebPasskeyService_BeginRegistration_Call) Return(ceremony string, options []byte, err error) *WebPasskeyService_BeginRegistration_Call { | |
| 196 | + | _c.Call.Return(ceremony, options, err) | |
| 197 | + | return _c | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func (_c *WebPasskeyService_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name string) (string, []byte, error)) *WebPasskeyService_BeginRegistration_Call { | |
| 201 | + | _c.Call.Return(run) | |
| 202 | + | return _c | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // FinishLogin provides a mock function for the type WebPasskeyService | |
| 206 | + | func (_mock *WebPasskeyService) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) { | |
| 207 | + | ret := _mock.Called(ctx, ceremony, response) | |
| 208 | + | ||
| 209 | + | if len(ret) == 0 { | |
| 210 | + | panic("no return value specified for FinishLogin") | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | var r0 string | |
| 214 | + | var r1 time.Duration | |
| 215 | + | var r2 error | |
| 216 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) (string, time.Duration, error)); ok { | |
| 217 | + | return returnFunc(ctx, ceremony, response) | |
| 218 | + | } | |
| 219 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) string); ok { | |
| 220 | + | r0 = returnFunc(ctx, ceremony, response) | |
| 221 | + | } else { | |
| 222 | + | r0 = ret.Get(0).(string) | |
| 223 | + | } | |
| 224 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, []byte) time.Duration); ok { | |
| 225 | + | r1 = returnFunc(ctx, ceremony, response) | |
| 226 | + | } else { | |
| 227 | + | r1 = ret.Get(1).(time.Duration) | |
| 228 | + | } | |
| 229 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string, []byte) error); ok { | |
| 230 | + | r2 = returnFunc(ctx, ceremony, response) | |
| 231 | + | } else { | |
| 232 | + | r2 = ret.Error(2) | |
| 233 | + | } | |
| 234 | + | return r0, r1, r2 | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // WebPasskeyService_FinishLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishLogin' | |
| 238 | + | type WebPasskeyService_FinishLogin_Call struct { | |
| 239 | + | *mock.Call | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // FinishLogin is a helper method to define mock.On call | |
| 243 | + | // - ctx context.Context | |
| 244 | + | // - ceremony string | |
| 245 | + | // - response []byte | |
| 246 | + | func (_e *WebPasskeyService_Expecter) FinishLogin(ctx any, ceremony any, response any) *WebPasskeyService_FinishLogin_Call { | |
| 247 | + | return &WebPasskeyService_FinishLogin_Call{Call: _e.mock.On("FinishLogin", ctx, ceremony, response)} | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (_c *WebPasskeyService_FinishLogin_Call) Run(run func(ctx context.Context, ceremony string, response []byte)) *WebPasskeyService_FinishLogin_Call { | |
| 251 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 252 | + | var arg0 context.Context | |
| 253 | + | if args[0] != nil { | |
| 254 | + | arg0 = args[0].(context.Context) | |
| 255 | + | } | |
| 256 | + | var arg1 string | |
| 257 | + | if args[1] != nil { | |
| 258 | + | arg1 = args[1].(string) | |
| 259 | + | } | |
| 260 | + | var arg2 []byte | |
| 261 | + | if args[2] != nil { | |
| 262 | + | arg2 = args[2].([]byte) | |
| 263 | + | } | |
| 264 | + | run( | |
| 265 | + | arg0, | |
| 266 | + | arg1, | |
| 267 | + | arg2, | |
| 268 | + | ) | |
| 269 | + | }) | |
| 270 | + | return _c | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func (_c *WebPasskeyService_FinishLogin_Call) Return(token string, lifetime time.Duration, err error) *WebPasskeyService_FinishLogin_Call { | |
| 274 | + | _c.Call.Return(token, lifetime, err) | |
| 275 | + | return _c | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func (_c *WebPasskeyService_FinishLogin_Call) RunAndReturn(run func(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error)) *WebPasskeyService_FinishLogin_Call { | |
| 279 | + | _c.Call.Return(run) | |
| 280 | + | return _c | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // FinishRegistration provides a mock function for the type WebPasskeyService | |
| 284 | + | func (_mock *WebPasskeyService) FinishRegistration(ctx context.Context, p domain.Principal, ceremony string, response []byte) error { | |
| 285 | + | ret := _mock.Called(ctx, p, ceremony, response) | |
| 286 | + | ||
| 287 | + | if len(ret) == 0 { | |
| 288 | + | panic("no return value specified for FinishRegistration") | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | var r0 error | |
| 292 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, []byte) error); ok { | |
| 293 | + | r0 = returnFunc(ctx, p, ceremony, response) | |
| 294 | + | } else { | |
| 295 | + | r0 = ret.Error(0) | |
| 296 | + | } | |
| 297 | + | return r0 | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // WebPasskeyService_FinishRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishRegistration' | |
| 301 | + | type WebPasskeyService_FinishRegistration_Call struct { | |
| 302 | + | *mock.Call | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // FinishRegistration is a helper method to define mock.On call | |
| 306 | + | // - ctx context.Context | |
| 307 | + | // - p domain.Principal | |
| 308 | + | // - ceremony string | |
| 309 | + | // - response []byte | |
| 310 | + | func (_e *WebPasskeyService_Expecter) FinishRegistration(ctx any, p any, ceremony any, response any) *WebPasskeyService_FinishRegistration_Call { | |
| 311 | + | return &WebPasskeyService_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", ctx, p, ceremony, response)} | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func (_c *WebPasskeyService_FinishRegistration_Call) Run(run func(ctx context.Context, p domain.Principal, ceremony string, response []byte)) *WebPasskeyService_FinishRegistration_Call { | |
| 315 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 316 | + | var arg0 context.Context | |
| 317 | + | if args[0] != nil { | |
| 318 | + | arg0 = args[0].(context.Context) | |
| 319 | + | } | |
| 320 | + | var arg1 domain.Principal | |
| 321 | + | if args[1] != nil { | |
| 322 | + | arg1 = args[1].(domain.Principal) | |
| 323 | + | } | |
| 324 | + | var arg2 string | |
| 325 | + | if args[2] != nil { | |
| 326 | + | arg2 = args[2].(string) | |
| 327 | + | } | |
| 328 | + | var arg3 []byte | |
| 329 | + | if args[3] != nil { | |
| 330 | + | arg3 = args[3].([]byte) | |
| 331 | + | } | |
| 332 | + | run( | |
| 333 | + | arg0, | |
| 334 | + | arg1, | |
| 335 | + | arg2, | |
| 336 | + | arg3, | |
| 337 | + | ) | |
| 338 | + | }) | |
| 339 | + | return _c | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func (_c *WebPasskeyService_FinishRegistration_Call) Return(err error) *WebPasskeyService_FinishRegistration_Call { | |
| 343 | + | _c.Call.Return(err) | |
| 344 | + | return _c | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func (_c *WebPasskeyService_FinishRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, ceremony string, response []byte) error) *WebPasskeyService_FinishRegistration_Call { | |
| 348 | + | _c.Call.Return(run) | |
| 349 | + | return _c | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // List provides a mock function for the type WebPasskeyService | |
| 353 | + | func (_mock *WebPasskeyService) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) { | |
| 354 | + | ret := _mock.Called(ctx, p) | |
| 355 | + | ||
| 356 | + | if len(ret) == 0 { | |
| 357 | + | panic("no return value specified for List") | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | var r0 []domain.Passkey | |
| 361 | + | var r1 error | |
| 362 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.Passkey, error)); ok { | |
| 363 | + | return returnFunc(ctx, p) | |
| 364 | + | } | |
| 365 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.Passkey); ok { | |
| 366 | + | r0 = returnFunc(ctx, p) | |
| 367 | + | } else { | |
| 368 | + | if ret.Get(0) != nil { | |
| 369 | + | r0 = ret.Get(0).([]domain.Passkey) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok { | |
| 373 | + | r1 = returnFunc(ctx, p) | |
| 374 | + | } else { | |
| 375 | + | r1 = ret.Error(1) | |
| 376 | + | } | |
| 377 | + | return r0, r1 | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | // WebPasskeyService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 381 | + | type WebPasskeyService_List_Call struct { | |
| 382 | + | *mock.Call | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // List is a helper method to define mock.On call | |
| 386 | + | // - ctx context.Context | |
| 387 | + | // - p domain.Principal | |
| 388 | + | func (_e *WebPasskeyService_Expecter) List(ctx any, p any) *WebPasskeyService_List_Call { | |
| 389 | + | return &WebPasskeyService_List_Call{Call: _e.mock.On("List", ctx, p)} | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func (_c *WebPasskeyService_List_Call) Run(run func(ctx context.Context, p domain.Principal)) *WebPasskeyService_List_Call { | |
| 393 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 394 | + | var arg0 context.Context | |
| 395 | + | if args[0] != nil { | |
| 396 | + | arg0 = args[0].(context.Context) | |
| 397 | + | } | |
| 398 | + | var arg1 domain.Principal | |
| 399 | + | if args[1] != nil { | |
| 400 | + | arg1 = args[1].(domain.Principal) | |
| 401 | + | } | |
| 402 | + | run( | |
| 403 | + | arg0, | |
| 404 | + | arg1, | |
| 405 | + | ) | |
| 406 | + | }) | |
| 407 | + | return _c | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | func (_c *WebPasskeyService_List_Call) Return(passkeys []domain.Passkey, err error) *WebPasskeyService_List_Call { | |
| 411 | + | _c.Call.Return(passkeys, err) | |
| 412 | + | return _c | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func (_c *WebPasskeyService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)) *WebPasskeyService_List_Call { | |
| 416 | + | _c.Call.Return(run) | |
| 417 | + | return _c | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // Remove provides a mock function for the type WebPasskeyService | |
| 421 | + | func (_mock *WebPasskeyService) Remove(ctx context.Context, p domain.Principal, id []byte) error { | |
| 422 | + | ret := _mock.Called(ctx, p, id) | |
| 423 | + | ||
| 424 | + | if len(ret) == 0 { | |
| 425 | + | panic("no return value specified for Remove") | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | var r0 error | |
| 429 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, []byte) error); ok { | |
| 430 | + | r0 = returnFunc(ctx, p, id) | |
| 431 | + | } else { | |
| 432 | + | r0 = ret.Error(0) | |
| 433 | + | } | |
| 434 | + | return r0 | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // WebPasskeyService_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove' | |
| 438 | + | type WebPasskeyService_Remove_Call struct { | |
| 439 | + | *mock.Call | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // Remove is a helper method to define mock.On call | |
| 443 | + | // - ctx context.Context | |
| 444 | + | // - p domain.Principal | |
| 445 | + | // - id []byte | |
| 446 | + | func (_e *WebPasskeyService_Expecter) Remove(ctx any, p any, id any) *WebPasskeyService_Remove_Call { | |
| 447 | + | return &WebPasskeyService_Remove_Call{Call: _e.mock.On("Remove", ctx, p, id)} | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func (_c *WebPasskeyService_Remove_Call) Run(run func(ctx context.Context, p domain.Principal, id []byte)) *WebPasskeyService_Remove_Call { | |
| 451 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 452 | + | var arg0 context.Context | |
| 453 | + | if args[0] != nil { | |
| 454 | + | arg0 = args[0].(context.Context) | |
| 455 | + | } | |
| 456 | + | var arg1 domain.Principal | |
| 457 | + | if args[1] != nil { | |
| 458 | + | arg1 = args[1].(domain.Principal) | |
| 459 | + | } | |
| 460 | + | var arg2 []byte | |
| 461 | + | if args[2] != nil { | |
| 462 | + | arg2 = args[2].([]byte) | |
| 463 | + | } | |
| 464 | + | run( | |
| 465 | + | arg0, | |
| 466 | + | arg1, | |
| 467 | + | arg2, | |
| 468 | + | ) | |
| 469 | + | }) | |
| 470 | + | return _c | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func (_c *WebPasskeyService_Remove_Call) Return(err error) *WebPasskeyService_Remove_Call { | |
| 474 | + | _c.Call.Return(err) | |
| 475 | + | return _c | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | func (_c *WebPasskeyService_Remove_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id []byte) error) *WebPasskeyService_Remove_Call { | |
| 479 | + | _c.Call.Return(run) | |
| 480 | + | return _c | |
| 481 | + | } | |
M internal/ports/ports.go +47 -0
| @@ -320,6 +320,53 @@ type SessionStore interface { | |||
| 320 | 320 | BumpEpoch(ctx context.Context) (uint64, error) | |
| 321 | 321 | } | |
| 322 | 322 | ||
| 323 | + | // PasskeyStore keeps the owner's passkeys (ADR 0026) and the WebAuthn user | |
| 324 | + | // handle they belong to. | |
| 325 | + | type PasskeyStore interface { | |
| 326 | + | // UserHandle returns the 32-byte WebAuthn user handle of the owner. The | |
| 327 | + | // first call on an empty store generates and stores it; it never changes. | |
| 328 | + | UserHandle(ctx context.Context) ([]byte, error) | |
| 329 | + | // List returns every passkey, oldest first. | |
| 330 | + | List(ctx context.Context) ([]domain.Passkey, error) | |
| 331 | + | // Get returns the passkey with that credential id, or domain.ErrNotFound. | |
| 332 | + | Get(ctx context.Context, id []byte) (domain.Passkey, error) | |
| 333 | + | // Create stores the passkey. It returns domain.ErrAlreadyExists if the | |
| 334 | + | // credential id is taken. It does not bound the number of passkeys: the | |
| 335 | + | // use case checks domain.MaxPasskeys. | |
| 336 | + | Create(ctx context.Context, p domain.Passkey) error | |
| 337 | + | // Update replaces the stored passkey (sign count, backup state, last | |
| 338 | + | // use), or returns domain.ErrNotFound. | |
| 339 | + | Update(ctx context.Context, p domain.Passkey) error | |
| 340 | + | // Delete removes the passkey, or returns domain.ErrNotFound. | |
| 341 | + | Delete(ctx context.Context, id []byte) error | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // PasskeyVerifier runs the WebAuthn ceremonies (ADR 0026). It hides the | |
| 345 | + | // WebAuthn library: the ceremony data it hands out is opaque to the caller, | |
| 346 | + | // which keeps it between the begin and the finish of a ceremony and passes it | |
| 347 | + | // back untouched. Every verification failure is domain.ErrUnauthenticated, | |
| 348 | + | // wrapped with a reason meant for logs, not for callers. | |
| 349 | + | type PasskeyVerifier interface { | |
| 350 | + | // BeginRegistration starts the registration of a passkey for the owner. | |
| 351 | + | // options is the JSON of the credential creation options for the browser | |
| 352 | + | // ({"publicKey": {...}}); existing passkeys are excluded. | |
| 353 | + | BeginRegistration(userHandle []byte, existing []domain.Passkey) (options []byte, ceremony []byte, err error) | |
| 354 | + | // FinishRegistration checks the browser's response (the JSON of | |
| 355 | + | // PublicKeyCredential.toJSON()) and returns the new passkey, without a name | |
| 356 | + | // or creation time. | |
| 357 | + | FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error) | |
| 358 | + | // BeginLogin starts a discoverable login. options is the JSON of the | |
| 359 | + | // credential request options for the browser ({"publicKey": {...}}). | |
| 360 | + | BeginLogin() (options []byte, ceremony []byte, err error) | |
| 361 | + | // FinishLogin checks the browser's response and returns the stored passkey | |
| 362 | + | // updated with the sign count and backup state the response reports. lookup | |
| 363 | + | // returns domain.ErrNotFound for an unknown credential id. A counter that | |
| 364 | + | // does not advance is not an error here: the returned SignCount is then the | |
| 365 | + | // stored one, and the caller, which holds the lock that orders the logins, | |
| 366 | + | // refuses it. | |
| 367 | + | FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) | |
| 368 | + | } | |
| 369 | + | ||
| 323 | 370 | // EventBus fans events out to in-process subscribers. | |
| 324 | 371 | type EventBus interface { | |
| 325 | 372 | // Publish hands the event to every subscriber without blocking. A | |
M internal/ports/services.go +35 -0
| @@ -117,6 +117,41 @@ type WebSessionService interface { | |||
| 117 | 117 | Verify(ctx context.Context, token string) (owner domain.Principal, ok bool, err error) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | + | // PasskeyService is the owner's passkey use cases the API serves. | |
| 121 | + | // *usecase.Passkeys implements it. Registering a passkey and signing in with | |
| 122 | + | // one are browser ceremonies and are not part of it. | |
| 123 | + | type PasskeyService interface { | |
| 124 | + | // List returns the passkeys, oldest first. | |
| 125 | + | List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) | |
| 126 | + | // Remove deletes the passkey with that credential id, or returns | |
| 127 | + | // domain.ErrNotFound, and signs every browser out. | |
| 128 | + | Remove(ctx context.Context, p domain.Principal, id []byte) error | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // WebPasskeyService is the passkey side of the use cases, as the web needs | |
| 132 | + | // it: the API's List and Remove, and the browser ceremonies. The composition | |
| 133 | + | // root implements it over *usecase.Passkeys. A ceremony is the opaque | |
| 134 | + | // identifier BeginLogin and BeginRegistration return; options is the JSON the | |
| 135 | + | // browser passes to the WebAuthn API. | |
| 136 | + | type WebPasskeyService interface { | |
| 137 | + | PasskeyService | |
| 138 | + | // BeginLogin starts a sign-in. Anonymous callers may. client is the client | |
| 139 | + | // key of the request (clientlimit.Key of the address the failure limiter | |
| 140 | + | // uses); it bounds how many sign-ins a client keeps waiting. | |
| 141 | + | BeginLogin(ctx context.Context, client string) (ceremony string, options []byte, err error) | |
| 142 | + | // FinishLogin checks the browser's response and returns the session token | |
| 143 | + | // and how long it lasts, or ErrLinkRejected for any refusal. | |
| 144 | + | FinishLogin(ctx context.Context, ceremony string, response []byte) (token string, lifetime time.Duration, err error) | |
| 145 | + | // BeginRegistration starts adding a passkey. Only the owner may. name is | |
| 146 | + | // the name the passkey will get (empty means the default): a name that | |
| 147 | + | // breaks the rules is refused here, before any ceremony starts, and is the | |
| 148 | + | // one FinishRegistration stores. | |
| 149 | + | BeginRegistration(ctx context.Context, p domain.Principal, name string) (ceremony string, options []byte, err error) | |
| 150 | + | // FinishRegistration checks the browser's response and stores the passkey | |
| 151 | + | // under the name given to BeginRegistration. | |
| 152 | + | FinishRegistration(ctx context.Context, p domain.Principal, ceremony string, response []byte) error | |
| 153 | + | } | |
| 154 | + | ||
| 120 | 155 | // PushService opens a repository for a push: it is domain.ErrUnauthenticated | |
| 121 | 156 | // for an anonymous caller, domain.ErrNotFound for a repository the caller may | |
| 122 | 157 | // not write and cannot see (or that is missing and may not be created), | |
A internal/usecase/passkeys.go +411 -0
| @@ -0,0 +1,411 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/rand" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/base64" | |
| 10 | + | "encoding/hex" | |
| 11 | + | "errors" | |
| 12 | + | "fmt" | |
| 13 | + | "sync" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // Owner passkeys (ADR 0026). A ceremony is a registration or a login that the | |
| 23 | + | // browser carries out between two requests; its data lives in memory only. | |
| 24 | + | const ( | |
| 25 | + | // MaxPendingPasskeyRegistrations bounds the registrations that wait to be | |
| 26 | + | // finished. Only the owner starts one. | |
| 27 | + | MaxPendingPasskeyRegistrations = 16 | |
| 28 | + | // MaxPendingPasskeyLogins bounds the logins that wait to be finished. | |
| 29 | + | // Anyone may start one, so a login is never refused: on overflow one is | |
| 30 | + | // dropped instead, and anonymous callers cannot lock the owner out. | |
| 31 | + | MaxPendingPasskeyLogins = 256 | |
| 32 | + | // MaxPendingPasskeyLoginsPerClient bounds the logins one client (one client | |
| 33 | + | // key) may have waiting. A client over it loses its own oldest. | |
| 34 | + | MaxPendingPasskeyLoginsPerClient = 8 | |
| 35 | + | ||
| 36 | + | // ceremonyIDBytes is the size of a ceremony identifier: 256 bits. | |
| 37 | + | ceremonyIDBytes = 32 | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | type ceremonyKind uint8 | |
| 41 | + | ||
| 42 | + | const ( | |
| 43 | + | ceremonyRegister ceremonyKind = iota + 1 | |
| 44 | + | ceremonyLogin | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | // ceremony is a begun registration or login. | |
| 48 | + | type ceremony struct { | |
| 49 | + | kind ceremonyKind | |
| 50 | + | data []byte // opaque, from the verifier | |
| 51 | + | expires time.Time | |
| 52 | + | seq uint64 // creation order | |
| 53 | + | client string // who began a login (its client key); "" for a registration | |
| 54 | + | name domain.PasskeyName // what a registration will call the passkey | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Passkeys are the use cases of the owner's passkeys: registering, signing in, | |
| 58 | + | // listing and removing them. | |
| 59 | + | type Passkeys struct { | |
| 60 | + | store ports.PasskeyStore | |
| 61 | + | verifier ports.PasskeyVerifier | |
| 62 | + | sessions *Sessions | |
| 63 | + | clock ports.Clock | |
| 64 | + | ||
| 65 | + | // writeMu serializes the writes to the store (Create, Update, Delete) and | |
| 66 | + | // the session they make or void, so that a removed passkey cannot sign in. | |
| 67 | + | writeMu sync.Mutex | |
| 68 | + | ||
| 69 | + | mu sync.Mutex | |
| 70 | + | ceremonies map[[sha256.Size]byte]ceremony // by hash of the identifier | |
| 71 | + | seq uint64 | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | var _ ports.PasskeyService = (*Passkeys)(nil) | |
| 75 | + | ||
| 76 | + | // NewPasskeys builds the passkey use cases. Sessions are issued and voided | |
| 77 | + | // through sessions. | |
| 78 | + | func NewPasskeys(store ports.PasskeyStore, verifier ports.PasskeyVerifier, sessions *Sessions, clock ports.Clock) *Passkeys { | |
| 79 | + | return &Passkeys{store: store, verifier: verifier, sessions: sessions, clock: clock, ceremonies: map[[sha256.Size]byte]ceremony{}} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // pendingLogins is what begin knows of the logins that wait, per client. | |
| 83 | + | type pendingLogins struct { | |
| 84 | + | count int | |
| 85 | + | oldest [sha256.Size]byte | |
| 86 | + | oldestSeq uint64 // 0 until a login is added: sequence numbers start at 1 | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (p *pendingLogins) add(h [sha256.Size]byte, seq uint64) { | |
| 90 | + | p.count++ | |
| 91 | + | if p.oldestSeq == 0 || seq < p.oldestSeq { | |
| 92 | + | p.oldest, p.oldestSeq = h, seq | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // begin keeps a ceremony and returns its identifier. It sweeps the expired | |
| 97 | + | // ones first. A registration over the limit gives domain.ErrResourceExhausted. | |
| 98 | + | // A login is never refused, but it can cost another login its place: over the | |
| 99 | + | // per-client cap it drops that client's own oldest; over the global cap it drops | |
| 100 | + | // the oldest of the client with the most logins waiting. A client that floods | |
| 101 | + | // therefore only ever loses its own ceremonies, never another client's. | |
| 102 | + | func (s *Passkeys) begin(c ceremony) (string, error) { | |
| 103 | + | var raw [ceremonyIDBytes]byte | |
| 104 | + | if _, err := rand.Read(raw[:]); err != nil { | |
| 105 | + | return "", fmt.Errorf("generate passkey ceremony: %w", err) | |
| 106 | + | } | |
| 107 | + | id := base64.RawURLEncoding.EncodeToString(raw[:]) | |
| 108 | + | now := s.clock.Now() | |
| 109 | + | ||
| 110 | + | s.mu.Lock() | |
| 111 | + | defer s.mu.Unlock() | |
| 112 | + | registrations := 0 | |
| 113 | + | logins := 0 | |
| 114 | + | byClient := map[string]*pendingLogins{} | |
| 115 | + | for h, c := range s.ceremonies { | |
| 116 | + | if !now.Before(c.expires) { | |
| 117 | + | delete(s.ceremonies, h) | |
| 118 | + | continue | |
| 119 | + | } | |
| 120 | + | switch c.kind { | |
| 121 | + | case ceremonyRegister: | |
| 122 | + | registrations++ | |
| 123 | + | case ceremonyLogin: | |
| 124 | + | logins++ | |
| 125 | + | pl := byClient[c.client] | |
| 126 | + | if pl == nil { | |
| 127 | + | pl = &pendingLogins{} | |
| 128 | + | byClient[c.client] = pl | |
| 129 | + | } | |
| 130 | + | pl.add(h, c.seq) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | switch c.kind { | |
| 134 | + | case ceremonyRegister: | |
| 135 | + | if registrations >= MaxPendingPasskeyRegistrations { | |
| 136 | + | return "", fmt.Errorf("%w: %d passkey registrations are already pending, finish one or wait two minutes", domain.ErrResourceExhausted, MaxPendingPasskeyRegistrations) | |
| 137 | + | } | |
| 138 | + | case ceremonyLogin: | |
| 139 | + | if pl := byClient[c.client]; pl != nil && pl.count >= MaxPendingPasskeyLoginsPerClient { | |
| 140 | + | delete(s.ceremonies, pl.oldest) | |
| 141 | + | logins-- | |
| 142 | + | // The client is back under its cap; the global step below needs | |
| 143 | + | // its new count and its new oldest. | |
| 144 | + | pl.count-- | |
| 145 | + | s.refreshOldest(pl, c.client) | |
| 146 | + | } | |
| 147 | + | if logins >= MaxPendingPasskeyLogins { | |
| 148 | + | // The client with the most waiting loses its oldest; among equals, | |
| 149 | + | // the one whose oldest waited longest. | |
| 150 | + | var victim *pendingLogins | |
| 151 | + | for _, pl := range byClient { | |
| 152 | + | if pl.count == 0 { | |
| 153 | + | continue | |
| 154 | + | } | |
| 155 | + | if victim == nil || pl.count > victim.count || (pl.count == victim.count && pl.oldestSeq < victim.oldestSeq) { | |
| 156 | + | victim = pl | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | if victim != nil { | |
| 160 | + | delete(s.ceremonies, victim.oldest) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | s.seq++ | |
| 165 | + | c.expires, c.seq = now.Add(domain.PasskeyCeremonyTTL), s.seq | |
| 166 | + | s.ceremonies[sha256.Sum256([]byte(id))] = c | |
| 167 | + | return id, nil | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // refreshOldest recomputes the oldest login of a client after one was dropped. | |
| 171 | + | // The caller holds s.mu. | |
| 172 | + | func (s *Passkeys) refreshOldest(pl *pendingLogins, client string) { | |
| 173 | + | pl.oldestSeq = 0 | |
| 174 | + | for h, c := range s.ceremonies { | |
| 175 | + | if c.kind != ceremonyLogin || c.client != client { | |
| 176 | + | continue | |
| 177 | + | } | |
| 178 | + | if pl.oldestSeq == 0 || c.seq < pl.oldestSeq { | |
| 179 | + | pl.oldest, pl.oldestSeq = h, c.seq | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // take removes the ceremony and returns it. It is single use, also when the | |
| 185 | + | // ceremony expired or is of another kind. ok is false for an unknown, expired or | |
| 186 | + | // mismatched ceremony. | |
| 187 | + | func (s *Passkeys) take(id string, kind ceremonyKind) (c ceremony, ok bool) { | |
| 188 | + | h := sha256.Sum256([]byte(id)) | |
| 189 | + | s.mu.Lock() | |
| 190 | + | c, found := s.ceremonies[h] | |
| 191 | + | delete(s.ceremonies, h) | |
| 192 | + | s.mu.Unlock() | |
| 193 | + | if !found || c.kind != kind || !s.clock.Now().Before(c.expires) { | |
| 194 | + | return ceremony{}, false | |
| 195 | + | } | |
| 196 | + | return c, true | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // BeginRegistration starts adding a passkey. Only the owner may. name is | |
| 200 | + | // checked now (an empty name means the default), so that a name the owner would | |
| 201 | + | // be prompted for and then see refused costs no ceremony; the ceremony keeps the | |
| 202 | + | // checked name and FinishRegistration stores it. It gives domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too | |
| 203 | + | // many registrations wait. It returns the ceremony identifier and the options | |
| 204 | + | // for the browser. | |
| 205 | + | func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal, name string) (string, []byte, error) { | |
| 206 | + | if err := requireOwner(p); err != nil { | |
| 207 | + | return "", nil, err | |
| 208 | + | } | |
| 209 | + | parsed, err := parsePasskeyName(name) | |
| 210 | + | if err != nil { | |
| 211 | + | return "", nil, err | |
| 212 | + | } | |
| 213 | + | existing, err := s.store.List(ctx) | |
| 214 | + | if err != nil { | |
| 215 | + | return "", nil, fmt.Errorf("list passkeys: %w", err) | |
| 216 | + | } | |
| 217 | + | if len(existing) >= domain.MaxPasskeys { | |
| 218 | + | return "", nil, fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys) | |
| 219 | + | } | |
| 220 | + | handle, err := s.store.UserHandle(ctx) | |
| 221 | + | if err != nil { | |
| 222 | + | return "", nil, fmt.Errorf("load passkey user handle: %w", err) | |
| 223 | + | } | |
| 224 | + | options, data, err := s.verifier.BeginRegistration(handle, existing) | |
| 225 | + | if err != nil { | |
| 226 | + | return "", nil, err | |
| 227 | + | } | |
| 228 | + | id, err := s.begin(ceremony{kind: ceremonyRegister, data: data, name: parsed}) | |
| 229 | + | if err != nil { | |
| 230 | + | return "", nil, err | |
| 231 | + | } | |
| 232 | + | return id, options, nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // FinishRegistration checks the browser's response and stores the passkey under | |
| 236 | + | // the name BeginRegistration checked. A ceremony that is unknown, expired, used | |
| 237 | + | // or of the wrong kind, and a response the verifier refuses, give | |
| 238 | + | // domain.ErrUnauthenticated; a credential already registered gives | |
| 239 | + | // domain.ErrAlreadyExists; too many passkeys, domain.ErrResourceExhausted. | |
| 240 | + | func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, ceremonyID string, response []byte) error { | |
| 241 | + | if err := requireOwner(p); err != nil { | |
| 242 | + | return err | |
| 243 | + | } | |
| 244 | + | log := zerowrap.FromCtx(ctx) | |
| 245 | + | c, ok := s.take(ceremonyID, ceremonyRegister) | |
| 246 | + | if !ok { | |
| 247 | + | err := fmt.Errorf("%w: passkey registration expired or unknown, start again", domain.ErrUnauthenticated) | |
| 248 | + | log.Info().Err(err).Msg("passkey registration refused") | |
| 249 | + | return err | |
| 250 | + | } | |
| 251 | + | handle, err := s.store.UserHandle(ctx) | |
| 252 | + | if err != nil { | |
| 253 | + | return fmt.Errorf("load passkey user handle: %w", err) | |
| 254 | + | } | |
| 255 | + | pk, err := s.verifier.FinishRegistration(handle, c.data, response) | |
| 256 | + | if err != nil { | |
| 257 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 258 | + | // The reason is for the log (it holds no key); the caller gets a 400. | |
| 259 | + | log.Info().Err(err).Msg("passkey registration refused") | |
| 260 | + | } | |
| 261 | + | return err | |
| 262 | + | } | |
| 263 | + | if n := len(pk.ID); n == 0 || n > domain.MaxPasskeyIDLen { | |
| 264 | + | return fmt.Errorf("%w: credential id has %d bytes", domain.ErrUnauthenticated, n) | |
| 265 | + | } | |
| 266 | + | pk.Name = c.name.String() | |
| 267 | + | pk.CreatedAt = s.clock.Now().UTC() | |
| 268 | + | pk.LastUsedAt = time.Time{} | |
| 269 | + | ||
| 270 | + | s.writeMu.Lock() | |
| 271 | + | defer s.writeMu.Unlock() | |
| 272 | + | existing, err := s.store.List(ctx) | |
| 273 | + | if err != nil { | |
| 274 | + | return fmt.Errorf("list passkeys: %w", err) | |
| 275 | + | } | |
| 276 | + | if len(existing) >= domain.MaxPasskeys { | |
| 277 | + | return fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys) | |
| 278 | + | } | |
| 279 | + | if err := s.store.Create(ctx, pk); err != nil { | |
| 280 | + | return err | |
| 281 | + | } | |
| 282 | + | log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey added") | |
| 283 | + | return nil | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // BeginLogin starts a passkey sign-in. Anonymous callers may. client is the | |
| 287 | + | // client key of the caller (clientlimit.Key of the address the failure limiter | |
| 288 | + | // uses); it bounds how many sign-ins one client keeps waiting. It returns the | |
| 289 | + | // ceremony identifier and the options for the browser. | |
| 290 | + | func (s *Passkeys) BeginLogin(_ context.Context, client string) (string, []byte, error) { | |
| 291 | + | options, data, err := s.verifier.BeginLogin() | |
| 292 | + | if err != nil { | |
| 293 | + | return "", nil, err | |
| 294 | + | } | |
| 295 | + | id, err := s.begin(ceremony{kind: ceremonyLogin, data: data, client: client}) | |
| 296 | + | if err != nil { | |
| 297 | + | return "", nil, err | |
| 298 | + | } | |
| 299 | + | return id, options, nil | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // FinishLogin checks the browser's response and opens the owner's session, | |
| 303 | + | // like a login link does. Whatever the reason of a refusal (unknown or | |
| 304 | + | // expired ceremony, unknown passkey, bad signature, missing user verification, | |
| 305 | + | // cloned authenticator), it gives domain.ErrUnauthenticated; a failure of the | |
| 306 | + | // store is returned as it is, so that the caller fails closed. | |
| 307 | + | func (s *Passkeys) FinishLogin(ctx context.Context, ceremonyID string, response []byte) (token string, expiresAt time.Time, err error) { | |
| 308 | + | log := zerowrap.FromCtx(ctx) | |
| 309 | + | c, ok := s.take(ceremonyID, ceremonyLogin) | |
| 310 | + | if !ok { | |
| 311 | + | log.Info().Msg("passkey sign-in refused: the ceremony expired or is unknown") | |
| 312 | + | return "", time.Time{}, domain.ErrUnauthenticated | |
| 313 | + | } | |
| 314 | + | handle, err := s.store.UserHandle(ctx) | |
| 315 | + | if err != nil { | |
| 316 | + | return "", time.Time{}, fmt.Errorf("load passkey user handle: %w", err) | |
| 317 | + | } | |
| 318 | + | pk, err := s.verifier.FinishLogin(handle, c.data, response, func(id []byte) (domain.Passkey, error) { return s.store.Get(ctx, id) }) | |
| 319 | + | if err != nil { | |
| 320 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 321 | + | // The reason is for the log (it holds no key); the caller learns nothing. | |
| 322 | + | log.Info().Err(err).Msg("passkey sign-in refused") | |
| 323 | + | return "", time.Time{}, domain.ErrUnauthenticated | |
| 324 | + | } | |
| 325 | + | return "", time.Time{}, fmt.Errorf("verify passkey login: %w", err) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // Recording the use and opening the session are one step with respect to | |
| 329 | + | // Remove and to a concurrent login, so that a passkey removed meanwhile | |
| 330 | + | // cannot sign in, nor two logins with the same counter. | |
| 331 | + | s.writeMu.Lock() | |
| 332 | + | defer s.writeMu.Unlock() | |
| 333 | + | cur, err := s.store.Get(ctx, pk.ID) | |
| 334 | + | if err != nil { | |
| 335 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 336 | + | log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the passkey was removed meanwhile") | |
| 337 | + | return "", time.Time{}, domain.ErrUnauthenticated | |
| 338 | + | } | |
| 339 | + | return "", time.Time{}, fmt.Errorf("read passkey: %w", err) | |
| 340 | + | } | |
| 341 | + | // The one sign-count check. The verifier returns the counter the response | |
| 342 | + | // reports (the stored one when it does not advance), and another login may | |
| 343 | + | // have moved the stored one since it looked. A counter that does not advance | |
| 344 | + | // is the clone signal; synced passkeys report 0 and are exempt. | |
| 345 | + | if cur.SignCount != 0 && pk.SignCount <= cur.SignCount { | |
| 346 | + | log.Warn().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned") | |
| 347 | + | return "", time.Time{}, domain.ErrUnauthenticated | |
| 348 | + | } | |
| 349 | + | pk.LastUsedAt = s.clock.Now().UTC() | |
| 350 | + | if err := s.store.Update(ctx, pk); err != nil { | |
| 351 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 352 | + | return "", time.Time{}, domain.ErrUnauthenticated | |
| 353 | + | } | |
| 354 | + | return "", time.Time{}, fmt.Errorf("record passkey use: %w", err) | |
| 355 | + | } | |
| 356 | + | token, expiresAt, err = s.sessions.issueOwner(ctx) | |
| 357 | + | if err != nil { | |
| 358 | + | return "", time.Time{}, err | |
| 359 | + | } | |
| 360 | + | log.Info().Str("passkey", passkeyRef(pk.ID)).Time("expires_at", expiresAt).Msg("owner signed in with a passkey") | |
| 361 | + | return token, expiresAt, nil | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | // List returns the passkeys, oldest first. Only the owner may. | |
| 365 | + | func (s *Passkeys) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) { | |
| 366 | + | if err := requireOwner(p); err != nil { | |
| 367 | + | return nil, err | |
| 368 | + | } | |
| 369 | + | return s.store.List(ctx) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // Remove deletes a passkey and signs every browser out, like revoking a token | |
| 373 | + | // with owner rights does. Only the owner may. An unknown id gives | |
| 374 | + | // domain.ErrNotFound. | |
| 375 | + | func (s *Passkeys) Remove(ctx context.Context, p domain.Principal, id []byte) error { | |
| 376 | + | if err := requireOwner(p); err != nil { | |
| 377 | + | return err | |
| 378 | + | } | |
| 379 | + | s.writeMu.Lock() | |
| 380 | + | defer s.writeMu.Unlock() | |
| 381 | + | if err := s.store.Delete(ctx, id); err != nil { | |
| 382 | + | return err | |
| 383 | + | } | |
| 384 | + | log := zerowrap.FromCtx(ctx) | |
| 385 | + | log.Info().Str("passkey", passkeyRef(id)).Str("removed_by", p.Label()).Str("removed_by_token", p.TokenID()).Msg("passkey removed") | |
| 386 | + | epoch, err := s.sessions.bumpEpoch(ctx) | |
| 387 | + | if err != nil { | |
| 388 | + | return fmt.Errorf("passkey %s removed, but signing out the web sessions failed: %w", passkeyRef(id), err) | |
| 389 | + | } | |
| 390 | + | log.Info().Str("passkey", passkeyRef(id)).Uint64("epoch", epoch).Msg("every web session signed out: a passkey was removed") | |
| 391 | + | return nil | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | // parsePasskeyName validates a name; an empty one is domain.DefaultPasskeyName. | |
| 395 | + | func parsePasskeyName(name string) (domain.PasskeyName, error) { | |
| 396 | + | if name == "" { | |
| 397 | + | name = domain.DefaultPasskeyName | |
| 398 | + | } | |
| 399 | + | parsed, err := domain.ParsePasskeyName(name) | |
| 400 | + | if err != nil { | |
| 401 | + | return domain.PasskeyName{}, fmt.Errorf("passkey name: %w", err) | |
| 402 | + | } | |
| 403 | + | return parsed, nil | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | // passkeyRef is how logs name a passkey: the first 8 hex characters of the | |
| 407 | + | // SHA-256 of its credential id. | |
| 408 | + | func passkeyRef(id []byte) string { | |
| 409 | + | sum := sha256.Sum256(id) | |
| 410 | + | return hex.EncodeToString(sum[:])[:8] | |
| 411 | + | } | |
A internal/usecase/passkeys_test.go +1064 -0
| @@ -0,0 +1,1064 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/hex" | |
| 10 | + | "encoding/json" | |
| 11 | + | "errors" | |
| 12 | + | "fmt" | |
| 13 | + | "slices" | |
| 14 | + | "strings" | |
| 15 | + | "sync" | |
| 16 | + | "sync/atomic" | |
| 17 | + | "testing" | |
| 18 | + | "time" | |
| 19 | + | ||
| 20 | + | "github.com/bnema/zerowrap" | |
| 21 | + | "github.com/stretchr/testify/assert" | |
| 22 | + | "github.com/stretchr/testify/mock" | |
| 23 | + | "github.com/stretchr/testify/require" | |
| 24 | + | ||
| 25 | + | "bnema.dev/ermite/internal/domain" | |
| 26 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 27 | + | "bnema.dev/ermite/internal/usecase" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | var testHandle = []byte("0123456789abcdef0123456789abcdef") | |
| 31 | + | ||
| 32 | + | type passkeyFixture struct { | |
| 33 | + | *sessionFixture | |
| 34 | + | store *mocks.PasskeyStore | |
| 35 | + | verifier *mocks.PasskeyVerifier | |
| 36 | + | passkeys *usecase.Passkeys | |
| 37 | + | ||
| 38 | + | mu sync.Mutex | |
| 39 | + | stored map[string]domain.Passkey // by credential id | |
| 40 | + | ceremon atomic.Int64 // makes the ceremony data of each begin unique | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func newPasskeyFixture(t *testing.T) *passkeyFixture { | |
| 44 | + | t.Helper() | |
| 45 | + | f := &passkeyFixture{ | |
| 46 | + | sessionFixture: newSessionFixture(t), | |
| 47 | + | store: mocks.NewPasskeyStore(t), | |
| 48 | + | verifier: mocks.NewPasskeyVerifier(t), | |
| 49 | + | stored: map[string]domain.Passkey{}, | |
| 50 | + | } | |
| 51 | + | f.store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil).Maybe() | |
| 52 | + | f.store.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Passkey, error) { | |
| 53 | + | f.mu.Lock() | |
| 54 | + | defer f.mu.Unlock() | |
| 55 | + | var out []domain.Passkey | |
| 56 | + | for _, p := range f.stored { | |
| 57 | + | out = append(out, p) | |
| 58 | + | } | |
| 59 | + | slices.SortFunc(out, func(a, b domain.Passkey) int { return bytes.Compare(a.ID, b.ID) }) | |
| 60 | + | return out, nil | |
| 61 | + | }).Maybe() | |
| 62 | + | f.store.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) (domain.Passkey, error) { | |
| 63 | + | f.mu.Lock() | |
| 64 | + | defer f.mu.Unlock() | |
| 65 | + | if p, ok := f.stored[string(id)]; ok { | |
| 66 | + | return p, nil | |
| 67 | + | } | |
| 68 | + | return domain.Passkey{}, domain.ErrNotFound | |
| 69 | + | }).Maybe() | |
| 70 | + | f.store.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error { | |
| 71 | + | f.mu.Lock() | |
| 72 | + | defer f.mu.Unlock() | |
| 73 | + | if _, ok := f.stored[string(p.ID)]; ok { | |
| 74 | + | return domain.ErrAlreadyExists | |
| 75 | + | } | |
| 76 | + | f.stored[string(p.ID)] = p | |
| 77 | + | return nil | |
| 78 | + | }).Maybe() | |
| 79 | + | f.store.EXPECT().Update(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error { | |
| 80 | + | f.mu.Lock() | |
| 81 | + | defer f.mu.Unlock() | |
| 82 | + | if _, ok := f.stored[string(p.ID)]; !ok { | |
| 83 | + | return domain.ErrNotFound | |
| 84 | + | } | |
| 85 | + | f.stored[string(p.ID)] = p | |
| 86 | + | return nil | |
| 87 | + | }).Maybe() | |
| 88 | + | f.store.EXPECT().Delete(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) error { | |
| 89 | + | f.mu.Lock() | |
| 90 | + | defer f.mu.Unlock() | |
| 91 | + | if _, ok := f.stored[string(id)]; !ok { | |
| 92 | + | return domain.ErrNotFound | |
| 93 | + | } | |
| 94 | + | delete(f.stored, string(id)) | |
| 95 | + | return nil | |
| 96 | + | }).Maybe() | |
| 97 | + | ||
| 98 | + | f.verifier.EXPECT().BeginRegistration(mock.Anything, mock.Anything).RunAndReturn(func([]byte, []domain.Passkey) ([]byte, []byte, error) { | |
| 99 | + | return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("register-%d", f.ceremon.Add(1))), nil | |
| 100 | + | }).Maybe() | |
| 101 | + | f.verifier.EXPECT().BeginLogin().RunAndReturn(func() ([]byte, []byte, error) { | |
| 102 | + | return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("login-%d", f.ceremon.Add(1))), nil | |
| 103 | + | }).Maybe() | |
| 104 | + | ||
| 105 | + | f.passkeys = usecase.NewPasskeys(f.store, f.verifier, f.sessions, f.clock) | |
| 106 | + | return f | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // add puts a passkey in the store directly. | |
| 110 | + | func (f *passkeyFixture) add(id string) domain.Passkey { | |
| 111 | + | p := domain.Passkey{ID: []byte(id), Name: id, PublicKey: []byte("k"), CreatedAt: *f.at.Load()} | |
| 112 | + | f.mu.Lock() | |
| 113 | + | f.stored[id] = p | |
| 114 | + | f.mu.Unlock() | |
| 115 | + | return p | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (f *passkeyFixture) count() int { | |
| 119 | + | f.mu.Lock() | |
| 120 | + | defer f.mu.Unlock() | |
| 121 | + | return len(f.stored) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // acceptRegistration makes the verifier accept any registration as the passkey. | |
| 125 | + | func (f *passkeyFixture) acceptRegistration(id string) { | |
| 126 | + | f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).RunAndReturn(func(_, ceremony, _ []byte) (domain.Passkey, error) { | |
| 127 | + | if !bytes.HasPrefix(ceremony, []byte("register-")) { | |
| 128 | + | return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony) | |
| 129 | + | } | |
| 130 | + | return domain.Passkey{ID: []byte(id), PublicKey: []byte("pub"), AttestationType: "none", Transports: []string{"internal"}, BackupEligible: true}, nil | |
| 131 | + | }).Maybe() | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // acceptLogin makes the verifier accept a login with the stored passkey `id`. | |
| 135 | + | // first is the counter of the first sign-in; like a real authenticator, each | |
| 136 | + | // later one reports a higher counter. 0 means a synced passkey: always 0. | |
| 137 | + | func (f *passkeyFixture) acceptLogin(id string, first uint32) { | |
| 138 | + | var next atomic.Uint32 | |
| 139 | + | next.Store(first) | |
| 140 | + | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 141 | + | func(_, ceremony, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 142 | + | if !bytes.HasPrefix(ceremony, []byte("login-")) { | |
| 143 | + | return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony) | |
| 144 | + | } | |
| 145 | + | p, err := lookup([]byte(id)) | |
| 146 | + | if err != nil { | |
| 147 | + | return domain.Passkey{}, fmt.Errorf("%w: lookup: %v", domain.ErrUnauthenticated, err) | |
| 148 | + | } | |
| 149 | + | if first != 0 { | |
| 150 | + | p.SignCount = next.Add(1) - 1 | |
| 151 | + | } | |
| 152 | + | p.BackupState = true | |
| 153 | + | return p, nil | |
| 154 | + | }).Maybe() | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func ownerAndOthers() map[string]struct { | |
| 158 | + | p domain.Principal | |
| 159 | + | err error | |
| 160 | + | } { | |
| 161 | + | partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]}) | |
| 162 | + | return map[string]struct { | |
| 163 | + | p domain.Principal | |
| 164 | + | err error | |
| 165 | + | }{ | |
| 166 | + | "root": {domain.Root(), nil}, | |
| 167 | + | "owner session": {domain.Owner(), nil}, | |
| 168 | + | "every scope": {allScopeToken(), nil}, | |
| 169 | + | "scoped token": {partial, domain.ErrForbidden}, | |
| 170 | + | "anonymous": {domain.Anonymous(), domain.ErrUnauthenticated}, | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestPasskeysAreOwnerOnly(t *testing.T) { | |
| 175 | + | t.Parallel() | |
| 176 | + | ctx := context.Background() | |
| 177 | + | for name, tc := range ownerAndOthers() { | |
| 178 | + | t.Run(name, func(t *testing.T) { | |
| 179 | + | t.Parallel() | |
| 180 | + | f := newPasskeyFixture(t) | |
| 181 | + | f.add("a") | |
| 182 | + | f.acceptRegistration("new") | |
| 183 | + | check := func(what string, err error) { | |
| 184 | + | t.Helper() | |
| 185 | + | if tc.err == nil { | |
| 186 | + | assert.NoError(t, err, what) | |
| 187 | + | } else { | |
| 188 | + | assert.ErrorIs(t, err, tc.err, what) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | _, _, err := f.passkeys.BeginRegistration(ctx, tc.p, "") | |
| 192 | + | check("begin registration", err) | |
| 193 | + | _, err = f.passkeys.List(ctx, tc.p) | |
| 194 | + | check("list", err) | |
| 195 | + | ||
| 196 | + | // A ceremony begun by the owner cannot be finished by anyone else. | |
| 197 | + | id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | err = f.passkeys.FinishRegistration(ctx, tc.p, id, []byte("{}")) | |
| 200 | + | check("finish registration", err) | |
| 201 | + | if tc.err != nil { | |
| 202 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte("{}")) | |
| 203 | + | assert.NoError(t, err, "a refused caller does not burn the ceremony") | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | err = f.passkeys.Remove(ctx, tc.p, []byte("a")) | |
| 207 | + | check("remove", err) | |
| 208 | + | if tc.err != nil { | |
| 209 | + | assert.Equal(t, 2, f.count(), "nothing removed") | |
| 210 | + | } | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestBeginLoginIsAnonymous(t *testing.T) { | |
| 216 | + | t.Parallel() | |
| 217 | + | f := newPasskeyFixture(t) | |
| 218 | + | id, options, err := f.passkeys.BeginLogin(context.Background(), "192.0.2.1") | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | assert.Len(t, id, 43, "256 bits") | |
| 221 | + | assert.JSONEq(t, `{"publicKey":{}}`, string(options)) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestRegistration(t *testing.T) { | |
| 225 | + | t.Parallel() | |
| 226 | + | ctx := context.Background() | |
| 227 | + | f := newPasskeyFixture(t) | |
| 228 | + | f.acceptRegistration("cred-1") | |
| 229 | + | ||
| 230 | + | id, options, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "Pixel 9") | |
| 231 | + | require.NoError(t, err) | |
| 232 | + | assert.JSONEq(t, `{"publicKey":{}}`, string(options)) | |
| 233 | + | ||
| 234 | + | require.NoError(t, f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`))) | |
| 235 | + | list, err := f.passkeys.List(ctx, domain.Owner()) | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | require.Len(t, list, 1) | |
| 238 | + | got := list[0] | |
| 239 | + | assert.Equal(t, "Pixel 9", got.Name, "the name given at begin") | |
| 240 | + | assert.Equal(t, *f.at.Load(), got.CreatedAt) | |
| 241 | + | assert.True(t, got.LastUsedAt.IsZero()) | |
| 242 | + | assert.Equal(t, []byte("cred-1"), got.ID) | |
| 243 | + | ||
| 244 | + | // The ceremony is single use. | |
| 245 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`)) | |
| 246 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // An empty name is the default one, and each ceremony keeps the name it began | |
| 250 | + | // with, whatever else is pending. | |
| 251 | + | func TestRegistrationNames(t *testing.T) { | |
| 252 | + | t.Parallel() | |
| 253 | + | ctx := context.Background() | |
| 254 | + | f := newPasskeyFixture(t) | |
| 255 | + | // The credential id is the ceremony data, so that two ceremonies finish as | |
| 256 | + | // two different passkeys. | |
| 257 | + | f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).RunAndReturn( | |
| 258 | + | func(_, ceremony, _ []byte) (domain.Passkey, error) { | |
| 259 | + | return domain.Passkey{ID: bytes.Clone(ceremony), PublicKey: []byte("pub")}, nil | |
| 260 | + | }) | |
| 261 | + | unnamed, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 262 | + | require.NoError(t, err) | |
| 263 | + | named, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "Second") | |
| 264 | + | require.NoError(t, err) | |
| 265 | + | // Finished in the other order. | |
| 266 | + | require.NoError(t, f.passkeys.FinishRegistration(ctx, domain.Owner(), named, []byte(`{}`))) | |
| 267 | + | require.NoError(t, f.passkeys.FinishRegistration(ctx, domain.Owner(), unnamed, []byte(`{}`))) | |
| 268 | + | ||
| 269 | + | names := map[string]string{} | |
| 270 | + | list, err := f.passkeys.List(ctx, domain.Owner()) | |
| 271 | + | require.NoError(t, err) | |
| 272 | + | for _, p := range list { | |
| 273 | + | names[string(p.ID)] = p.Name | |
| 274 | + | } | |
| 275 | + | assert.ElementsMatch(t, []string{"Second", domain.DefaultPasskeyName}, []string{names["register-2"], names["register-1"]}) | |
| 276 | + | assert.Equal(t, "Second", names["register-2"]) | |
| 277 | + | assert.Equal(t, domain.DefaultPasskeyName, names["register-1"]) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // What FinishRegistration refuses, and what is left in the store each time. | |
| 281 | + | func TestRegistrationRefusals(t *testing.T) { | |
| 282 | + | t.Parallel() | |
| 283 | + | tests := []struct { | |
| 284 | + | name string | |
| 285 | + | existing []string // passkeys already stored | |
| 286 | + | verifier func(f *passkeyFixture) | |
| 287 | + | wantErr error | |
| 288 | + | wantKept int // passkeys in the store afterwards | |
| 289 | + | }{ | |
| 290 | + | { | |
| 291 | + | name: "duplicate credential", | |
| 292 | + | existing: []string{"cred-1"}, | |
| 293 | + | verifier: func(f *passkeyFixture) { f.acceptRegistration("cred-1") }, | |
| 294 | + | wantErr: domain.ErrAlreadyExists, wantKept: 1, | |
| 295 | + | }, | |
| 296 | + | { | |
| 297 | + | name: "refused by the verifier", | |
| 298 | + | verifier: func(f *passkeyFixture) { | |
| 299 | + | f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything). | |
| 300 | + | Return(domain.Passkey{}, fmt.Errorf("%w: bad origin", domain.ErrUnauthenticated)) | |
| 301 | + | }, | |
| 302 | + | wantErr: domain.ErrUnauthenticated, wantKept: 0, | |
| 303 | + | }, | |
| 304 | + | { | |
| 305 | + | name: "credential id too long", | |
| 306 | + | verifier: func(f *passkeyFixture) { | |
| 307 | + | f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything). | |
| 308 | + | Return(domain.Passkey{ID: make([]byte, domain.MaxPasskeyIDLen+1), PublicKey: []byte("k")}, nil) | |
| 309 | + | }, | |
| 310 | + | wantErr: domain.ErrUnauthenticated, wantKept: 0, | |
| 311 | + | }, | |
| 312 | + | { | |
| 313 | + | name: "empty credential id", | |
| 314 | + | verifier: func(f *passkeyFixture) { | |
| 315 | + | f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything). | |
| 316 | + | Return(domain.Passkey{PublicKey: []byte("k")}, nil) | |
| 317 | + | }, | |
| 318 | + | wantErr: domain.ErrUnauthenticated, wantKept: 0, | |
| 319 | + | }, | |
| 320 | + | } | |
| 321 | + | for _, tc := range tests { | |
| 322 | + | t.Run(tc.name, func(t *testing.T) { | |
| 323 | + | t.Parallel() | |
| 324 | + | ctx := context.Background() | |
| 325 | + | f := newPasskeyFixture(t) | |
| 326 | + | for _, id := range tc.existing { | |
| 327 | + | f.add(id) | |
| 328 | + | } | |
| 329 | + | tc.verifier(f) | |
| 330 | + | id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "x") | |
| 331 | + | require.NoError(t, err) | |
| 332 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`)) | |
| 333 | + | assert.ErrorIs(t, err, tc.wantErr) | |
| 334 | + | assert.Equal(t, tc.wantKept, f.count()) | |
| 335 | + | }) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // The verifier is told about the passkeys to exclude. | |
| 340 | + | func TestBeginRegistrationExcludesExisting(t *testing.T) { | |
| 341 | + | t.Parallel() | |
| 342 | + | f := newPasskeyFixture(t) | |
| 343 | + | f.add("a") | |
| 344 | + | f.add("b") | |
| 345 | + | f.verifier.ExpectedCalls = nil // replace the permissive default | |
| 346 | + | f.verifier.EXPECT().BeginRegistration(testHandle, mock.MatchedBy(func(ps []domain.Passkey) bool { | |
| 347 | + | return len(ps) == 2 && string(ps[0].ID) == "a" && string(ps[1].ID) == "b" | |
| 348 | + | })).Return([]byte(`{}`), []byte("register-x"), nil).Once() | |
| 349 | + | _, _, err := f.passkeys.BeginRegistration(context.Background(), domain.Owner(), "") | |
| 350 | + | require.NoError(t, err) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func TestMaxPasskeys(t *testing.T) { | |
| 354 | + | t.Parallel() | |
| 355 | + | ctx := context.Background() | |
| 356 | + | f := newPasskeyFixture(t) | |
| 357 | + | f.acceptRegistration("one-more") | |
| 358 | + | for i := range domain.MaxPasskeys - 1 { | |
| 359 | + | f.add(fmt.Sprintf("p%02d", i)) | |
| 360 | + | } | |
| 361 | + | // A ceremony begun with room for one more. | |
| 362 | + | id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 363 | + | require.NoError(t, err) | |
| 364 | + | f.add("last") | |
| 365 | + | assert.Equal(t, domain.MaxPasskeys, f.count()) | |
| 366 | + | ||
| 367 | + | // Full: no new ceremony, and the one in flight is refused at the end. | |
| 368 | + | _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 369 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 370 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`)) | |
| 371 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 372 | + | assert.Equal(t, domain.MaxPasskeys, f.count()) | |
| 373 | + | ||
| 374 | + | // Removing one makes room again. | |
| 375 | + | require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("last"))) | |
| 376 | + | _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 377 | + | assert.NoError(t, err) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func TestCeremonyExpiry(t *testing.T) { | |
| 381 | + | t.Parallel() | |
| 382 | + | ctx := context.Background() | |
| 383 | + | f := newPasskeyFixture(t) | |
| 384 | + | f.acceptRegistration("cred-1") | |
| 385 | + | f.add("login-cred") | |
| 386 | + | f.acceptLogin("login-cred", 1) | |
| 387 | + | ||
| 388 | + | // One second short of the TTL still works. | |
| 389 | + | reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 390 | + | require.NoError(t, err) | |
| 391 | + | login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 392 | + | require.NoError(t, err) | |
| 393 | + | f.advance(domain.PasskeyCeremonyTTL - time.Second) | |
| 394 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, []byte(`{}`)) | |
| 395 | + | assert.NoError(t, err) | |
| 396 | + | _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`)) | |
| 397 | + | assert.NoError(t, err) | |
| 398 | + | ||
| 399 | + | // At the TTL it is gone, and it stays gone. | |
| 400 | + | reg, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 401 | + | require.NoError(t, err) | |
| 402 | + | login, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 403 | + | require.NoError(t, err) | |
| 404 | + | f.advance(domain.PasskeyCeremonyTTL) | |
| 405 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, []byte(`{}`)) | |
| 406 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 407 | + | _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`)) | |
| 408 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // Expired ceremonies are swept on begin, so they stop counting. | |
| 412 | + | func TestExpiredRegistrationsDoNotCount(t *testing.T) { | |
| 413 | + | t.Parallel() | |
| 414 | + | ctx := context.Background() | |
| 415 | + | f := newPasskeyFixture(t) | |
| 416 | + | for range usecase.MaxPendingPasskeyRegistrations { | |
| 417 | + | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 418 | + | require.NoError(t, err) | |
| 419 | + | } | |
| 420 | + | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 421 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 422 | + | f.advance(domain.PasskeyCeremonyTTL) | |
| 423 | + | _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 424 | + | assert.NoError(t, err) | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func TestPendingRegistrationsAreBounded(t *testing.T) { | |
| 428 | + | t.Parallel() | |
| 429 | + | ctx := context.Background() | |
| 430 | + | f := newPasskeyFixture(t) | |
| 431 | + | f.acceptRegistration("cred-1") | |
| 432 | + | var ids []string | |
| 433 | + | for range usecase.MaxPendingPasskeyRegistrations { | |
| 434 | + | id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 435 | + | require.NoError(t, err) | |
| 436 | + | ids = append(ids, id) | |
| 437 | + | } | |
| 438 | + | assert.Len(t, ids, 16) | |
| 439 | + | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 440 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted, "the 17th is refused") | |
| 441 | + | ||
| 442 | + | // Pending logins do not use the registrations' room, and a finished | |
| 443 | + | // registration frees one. | |
| 444 | + | _, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 445 | + | require.NoError(t, err) | |
| 446 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), ids[0], []byte(`{}`)) | |
| 447 | + | require.NoError(t, err) | |
| 448 | + | _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 449 | + | assert.NoError(t, err) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | // loginIDs begins n logins for the client, a millisecond apart so that the order | |
| 453 | + | // of creation is the order of time too. | |
| 454 | + | func (f *passkeyFixture) loginIDs(t *testing.T, client string, n int) []string { | |
| 455 | + | t.Helper() | |
| 456 | + | var ids []string | |
| 457 | + | for range n { | |
| 458 | + | id, _, err := f.passkeys.BeginLogin(context.Background(), client) | |
| 459 | + | require.NoError(t, err, "a login is never refused") | |
| 460 | + | ids = append(ids, id) | |
| 461 | + | f.advance(time.Millisecond) | |
| 462 | + | } | |
| 463 | + | return ids | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | // finishes tells whether the ceremony still opens a session. | |
| 467 | + | func (f *passkeyFixture) finishes(id string) bool { | |
| 468 | + | _, _, err := f.passkeys.FinishLogin(context.Background(), id, []byte(`{}`)) | |
| 469 | + | return err == nil | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | // A client keeps at most 8 logins waiting: the 9th costs it its own oldest. | |
| 473 | + | func TestPendingLoginsPerClientCap(t *testing.T) { | |
| 474 | + | t.Parallel() | |
| 475 | + | f := newPasskeyFixture(t) | |
| 476 | + | f.add("cred-1") | |
| 477 | + | f.acceptLogin("cred-1", 1) | |
| 478 | + | ||
| 479 | + | other := f.loginIDs(t, "198.51.100.7", 1)[0] | |
| 480 | + | ids := f.loginIDs(t, "192.0.2.1", usecase.MaxPendingPasskeyLoginsPerClient) | |
| 481 | + | assert.Equal(t, 8, usecase.MaxPendingPasskeyLoginsPerClient) | |
| 482 | + | newest := f.loginIDs(t, "192.0.2.1", 1)[0] | |
| 483 | + | ||
| 484 | + | assert.False(t, f.finishes(ids[0]), "the 9th evicted this client's oldest") | |
| 485 | + | assert.True(t, f.finishes(ids[1]), "the next oldest is still there") | |
| 486 | + | assert.True(t, f.finishes(newest), "the newest works") | |
| 487 | + | assert.True(t, f.finishes(other), "another client's ceremony is untouched") | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | // One address flooding can never evict another address's ceremony. | |
| 491 | + | func TestLoginFloodDoesNotEvictAnotherClient(t *testing.T) { | |
| 492 | + | t.Parallel() | |
| 493 | + | f := newPasskeyFixture(t) | |
| 494 | + | f.add("cred-1") | |
| 495 | + | f.acceptLogin("cred-1", 1) | |
| 496 | + | ||
| 497 | + | owner := f.loginIDs(t, "198.51.100.7", 1)[0] | |
| 498 | + | f.loginIDs(t, "192.0.2.1", 2*usecase.MaxPendingPasskeyLogins) | |
| 499 | + | assert.True(t, f.finishes(owner), "the owner's ceremony from another address still works") | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | // Over the global cap, the client with the most waiting loses its oldest; a | |
| 503 | + | // client with one ceremony is never the victim. | |
| 504 | + | func TestPendingLoginsGlobalOverflow(t *testing.T) { | |
| 505 | + | t.Parallel() | |
| 506 | + | f := newPasskeyFixture(t) | |
| 507 | + | f.add("cred-1") | |
| 508 | + | f.acceptLogin("cred-1", 1) | |
| 509 | + | ||
| 510 | + | owner := f.loginIDs(t, "198.51.100.7", 1)[0] | |
| 511 | + | perClient := usecase.MaxPendingPasskeyLoginsPerClient | |
| 512 | + | var first, second []string | |
| 513 | + | n := 0 | |
| 514 | + | for total := 1; total+perClient <= usecase.MaxPendingPasskeyLogins; total += perClient { | |
| 515 | + | ids := f.loginIDs(t, fmt.Sprintf("10.0.%d.1", n), perClient) | |
| 516 | + | if n == 0 { | |
| 517 | + | first = ids | |
| 518 | + | } | |
| 519 | + | if n == 1 { | |
| 520 | + | second = ids | |
| 521 | + | } | |
| 522 | + | n++ | |
| 523 | + | } | |
| 524 | + | // 1 + 31*8 = 249 waiting; top up to the cap with a client below the cap. | |
| 525 | + | small := f.loginIDs(t, "10.1.0.1", usecase.MaxPendingPasskeyLogins-1-n*perClient) | |
| 526 | + | require.Len(t, small, 7) | |
| 527 | + | ||
| 528 | + | // The 257th comes from a client that has none: nobody is refused, the | |
| 529 | + | // oldest of a client with 8 waiting goes (the one that waited longest). | |
| 530 | + | fresh := f.loginIDs(t, "203.0.113.9", 1)[0] | |
| 531 | + | assert.False(t, f.finishes(first[0]), "the oldest of the fullest, oldest client was evicted") | |
| 532 | + | assert.True(t, f.finishes(first[1])) | |
| 533 | + | assert.True(t, f.finishes(second[0]), "other full clients keep theirs") | |
| 534 | + | assert.True(t, f.finishes(owner), "a client with one ceremony is never the victim") | |
| 535 | + | assert.True(t, f.finishes(small[0]), "nor one below the others") | |
| 536 | + | assert.True(t, f.finishes(fresh), "the new login works") | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | // Logins never push out a registration, and registrations never push out a login. | |
| 540 | + | func TestCeremonyKindsAreSeparate(t *testing.T) { | |
| 541 | + | t.Parallel() | |
| 542 | + | ctx := context.Background() | |
| 543 | + | f := newPasskeyFixture(t) | |
| 544 | + | f.add("cred-1") | |
| 545 | + | f.acceptLogin("cred-1", 1) | |
| 546 | + | f.acceptRegistration("cred-2") | |
| 547 | + | ||
| 548 | + | reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 549 | + | require.NoError(t, err) | |
| 550 | + | login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 551 | + | require.NoError(t, err) | |
| 552 | + | ||
| 553 | + | // A registration ceremony does not finish a login, nor the reverse; the | |
| 554 | + | // attempt burns it. | |
| 555 | + | _, _, err = f.passkeys.FinishLogin(ctx, reg, []byte(`{}`)) | |
| 556 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 557 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), login, []byte(`{}`)) | |
| 558 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 559 | + | _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`)) | |
| 560 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "burned by the mismatch") | |
| 561 | + | assert.Equal(t, 1, f.count(), "no passkey was added") | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | func TestUnknownCeremony(t *testing.T) { | |
| 565 | + | t.Parallel() | |
| 566 | + | ctx := context.Background() | |
| 567 | + | f := newPasskeyFixture(t) | |
| 568 | + | _, _, err := f.passkeys.FinishLogin(ctx, "", []byte(`{}`)) | |
| 569 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 570 | + | _, _, err = f.passkeys.FinishLogin(ctx, "nope", []byte(`{}`)) | |
| 571 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 572 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "nope", []byte(`{}`)) | |
| 573 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | // A passkey login gives a session that Verify accepts, and records the use. | |
| 577 | + | func TestLoginIssuesASession(t *testing.T) { | |
| 578 | + | t.Parallel() | |
| 579 | + | ctx := context.Background() | |
| 580 | + | f := newPasskeyFixture(t) | |
| 581 | + | f.add("cred-1") | |
| 582 | + | f.acceptLogin("cred-1", 9) | |
| 583 | + | ||
| 584 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 585 | + | require.NoError(t, err) | |
| 586 | + | f.advance(10 * time.Second) | |
| 587 | + | token, exp, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 588 | + | require.NoError(t, err) | |
| 589 | + | assert.Equal(t, f.at.Load().Add(usecase.SessionTTL).Truncate(time.Second), exp) | |
| 590 | + | ||
| 591 | + | p, err := f.sessions.Verify(ctx, token) | |
| 592 | + | require.NoError(t, err) | |
| 593 | + | assert.Equal(t, "owner", p.Label()) | |
| 594 | + | assert.True(t, p.HasOwnerRights()) | |
| 595 | + | ||
| 596 | + | list, err := f.passkeys.List(ctx, domain.Owner()) | |
| 597 | + | require.NoError(t, err) | |
| 598 | + | require.Len(t, list, 1) | |
| 599 | + | assert.Equal(t, uint32(9), list[0].SignCount) | |
| 600 | + | assert.True(t, list[0].BackupState) | |
| 601 | + | assert.Equal(t, *f.at.Load(), list[0].LastUsedAt) | |
| 602 | + | ||
| 603 | + | // The ceremony is single use. | |
| 604 | + | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 605 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | // logCtx is a context whose logger writes JSON lines into the buffer. | |
| 609 | + | func logCtx(t *testing.T) (context.Context, *syncLog) { | |
| 610 | + | t.Helper() | |
| 611 | + | buf := &syncLog{} | |
| 612 | + | log := zerowrap.New(zerowrap.Config{Level: "trace", Format: "json", Output: buf}) | |
| 613 | + | return zerowrap.WithCtx(context.Background(), log), buf | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | type syncLog struct { | |
| 617 | + | mu sync.Mutex | |
| 618 | + | b bytes.Buffer | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | func (l *syncLog) Write(p []byte) (int, error) { | |
| 622 | + | l.mu.Lock() | |
| 623 | + | defer l.mu.Unlock() | |
| 624 | + | return l.b.Write(p) | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | func (l *syncLog) String() string { | |
| 628 | + | l.mu.Lock() | |
| 629 | + | defer l.mu.Unlock() | |
| 630 | + | return l.b.String() | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // logEntries parses the JSON lines of the buffer. | |
| 634 | + | func logEntries(t *testing.T, logs *syncLog) []map[string]any { | |
| 635 | + | t.Helper() | |
| 636 | + | var out []map[string]any | |
| 637 | + | for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { | |
| 638 | + | if line == "" { | |
| 639 | + | continue | |
| 640 | + | } | |
| 641 | + | var m map[string]any | |
| 642 | + | require.NoError(t, json.Unmarshal([]byte(line), &m), line) | |
| 643 | + | out = append(out, m) | |
| 644 | + | } | |
| 645 | + | return out | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | // The reason of a refusal is logged at info, never answered, and holds no | |
| 649 | + | // secret: neither the response bytes nor a key. | |
| 650 | + | func TestRefusalsAreLogged(t *testing.T) { | |
| 651 | + | t.Parallel() | |
| 652 | + | ||
| 653 | + | t.Run("sign-in", func(t *testing.T) { | |
| 654 | + | t.Parallel() | |
| 655 | + | ctx, logs := logCtx(t) | |
| 656 | + | f := newPasskeyFixture(t) | |
| 657 | + | f.add("cred-1") | |
| 658 | + | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything). | |
| 659 | + | Return(domain.Passkey{}, fmt.Errorf("%w: passkey login: origin mismatch", domain.ErrUnauthenticated)) | |
| 660 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 661 | + | require.NoError(t, err) | |
| 662 | + | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{"secret":"response-bytes"}`)) | |
| 663 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 664 | + | assert.NotContains(t, err.Error(), "origin mismatch", "the caller learns nothing") | |
| 665 | + | entries := logEntries(t, logs) | |
| 666 | + | require.Len(t, entries, 1) | |
| 667 | + | assert.Equal(t, "info", entries[0]["level"]) | |
| 668 | + | assert.Contains(t, entries[0]["error"], "origin mismatch") | |
| 669 | + | assert.NotContains(t, logs.String(), "response-bytes") | |
| 670 | + | }) | |
| 671 | + | ||
| 672 | + | t.Run("sign-in with an unknown ceremony", func(t *testing.T) { | |
| 673 | + | t.Parallel() | |
| 674 | + | ctx, logs := logCtx(t) | |
| 675 | + | f := newPasskeyFixture(t) | |
| 676 | + | _, _, err := f.passkeys.FinishLogin(ctx, "nope", []byte(`{"secret":"response-bytes"}`)) | |
| 677 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 678 | + | entries := logEntries(t, logs) | |
| 679 | + | require.Len(t, entries, 1) | |
| 680 | + | assert.Equal(t, "info", entries[0]["level"]) | |
| 681 | + | assert.NotContains(t, logs.String(), "response-bytes") | |
| 682 | + | }) | |
| 683 | + | ||
| 684 | + | t.Run("registration", func(t *testing.T) { | |
| 685 | + | t.Parallel() | |
| 686 | + | ctx, logs := logCtx(t) | |
| 687 | + | f := newPasskeyFixture(t) | |
| 688 | + | f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything). | |
| 689 | + | Return(domain.Passkey{}, fmt.Errorf("%w: passkey registration: user verification missing", domain.ErrUnauthenticated)) | |
| 690 | + | id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "x") | |
| 691 | + | require.NoError(t, err) | |
| 692 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{"secret":"response-bytes"}`)) | |
| 693 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 694 | + | entries := logEntries(t, logs) | |
| 695 | + | require.Len(t, entries, 1) | |
| 696 | + | assert.Equal(t, "info", entries[0]["level"]) | |
| 697 | + | assert.Contains(t, entries[0]["error"], "user verification missing") | |
| 698 | + | assert.NotContains(t, logs.String(), "response-bytes") | |
| 699 | + | ||
| 700 | + | // An unknown ceremony is logged too. | |
| 701 | + | err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "unknown", []byte(`{}`)) | |
| 702 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 703 | + | entries = logEntries(t, logs) | |
| 704 | + | require.Len(t, entries, 2) | |
| 705 | + | assert.Equal(t, "info", entries[1]["level"]) | |
| 706 | + | }) | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | // A counter that does not advance on a passkey that has one is refused, and the | |
| 710 | + | // warning names the passkey by its short reference. The library hands back the | |
| 711 | + | // stored counter when the received one is not higher, which is what the verifier | |
| 712 | + | // returns here. | |
| 713 | + | func TestCloneIsRefusedAndNamed(t *testing.T) { | |
| 714 | + | t.Parallel() | |
| 715 | + | ctx, logs := logCtx(t) | |
| 716 | + | f := newPasskeyFixture(t) | |
| 717 | + | p := f.add("cred-1") | |
| 718 | + | p.SignCount = 5 | |
| 719 | + | f.mu.Lock() | |
| 720 | + | f.stored["cred-1"] = p | |
| 721 | + | f.mu.Unlock() | |
| 722 | + | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 723 | + | func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 724 | + | return lookup([]byte("cred-1")) // SignCount stays 5 | |
| 725 | + | }) | |
| 726 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 727 | + | require.NoError(t, err) | |
| 728 | + | token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 729 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 730 | + | assert.Empty(t, token, "no session") | |
| 731 | + | assert.Equal(t, 1, f.count()) | |
| 732 | + | f.mu.Lock() | |
| 733 | + | assert.True(t, f.stored["cred-1"].LastUsedAt.IsZero(), "nothing was recorded") | |
| 734 | + | f.mu.Unlock() | |
| 735 | + | ||
| 736 | + | sum := sha256.Sum256([]byte("cred-1")) | |
| 737 | + | ref := hex.EncodeToString(sum[:])[:8] | |
| 738 | + | entries := logEntries(t, logs) | |
| 739 | + | require.Len(t, entries, 1, "one log line for the refusal") | |
| 740 | + | assert.Equal(t, "warn", entries[0]["level"]) | |
| 741 | + | assert.Equal(t, ref, entries[0]["passkey"]) | |
| 742 | + | assert.NotContains(t, logs.String(), "cred-1", "the credential id itself is never logged") | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | // Two logins verified against the same stored counter cannot both | |
| 746 | + | // succeed. The verifier saw counter 5; by the time the second one writes, the | |
| 747 | + | // first has stored 6. | |
| 748 | + | func TestLoginRefusesACounterThatDidNotAdvance(t *testing.T) { | |
| 749 | + | t.Parallel() | |
| 750 | + | ctx := context.Background() | |
| 751 | + | ||
| 752 | + | t.Run("another login moved the counter meanwhile", func(t *testing.T) { | |
| 753 | + | t.Parallel() | |
| 754 | + | f := newPasskeyFixture(t) | |
| 755 | + | stored := f.add("cred-1") | |
| 756 | + | stored.SignCount = 5 | |
| 757 | + | f.mu.Lock() | |
| 758 | + | f.stored["cred-1"] = stored | |
| 759 | + | f.mu.Unlock() | |
| 760 | + | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 761 | + | func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 762 | + | p, err := lookup([]byte("cred-1")) | |
| 763 | + | require.NoError(t, err) | |
| 764 | + | p.SignCount = 6 // what the response reports, against the 5 it was shown | |
| 765 | + | // A concurrent login stores 6 before this one gets the lock. | |
| 766 | + | f.mu.Lock() | |
| 767 | + | f.stored["cred-1"] = p | |
| 768 | + | f.mu.Unlock() | |
| 769 | + | return p, nil | |
| 770 | + | }) | |
| 771 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 772 | + | require.NoError(t, err) | |
| 773 | + | token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 774 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 775 | + | assert.Empty(t, token, "no session") | |
| 776 | + | f.mu.Lock() | |
| 777 | + | assert.True(t, f.stored["cred-1"].LastUsedAt.IsZero(), "nothing was recorded") | |
| 778 | + | f.mu.Unlock() | |
| 779 | + | }) | |
| 780 | + | ||
| 781 | + | t.Run("a counter that advances is accepted", func(t *testing.T) { | |
| 782 | + | t.Parallel() | |
| 783 | + | f := newPasskeyFixture(t) | |
| 784 | + | p := f.add("cred-1") | |
| 785 | + | p.SignCount = 5 | |
| 786 | + | f.mu.Lock() | |
| 787 | + | f.stored["cred-1"] = p | |
| 788 | + | f.mu.Unlock() | |
| 789 | + | f.acceptLogin("cred-1", 6) | |
| 790 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 791 | + | require.NoError(t, err) | |
| 792 | + | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 793 | + | assert.NoError(t, err) | |
| 794 | + | }) | |
| 795 | + | ||
| 796 | + | t.Run("a synced passkey reports 0 and is exempt", func(t *testing.T) { | |
| 797 | + | t.Parallel() | |
| 798 | + | f := newPasskeyFixture(t) | |
| 799 | + | f.add("cred-1") // stored counter 0 | |
| 800 | + | f.acceptLogin("cred-1", 0) | |
| 801 | + | for range 2 { | |
| 802 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 803 | + | require.NoError(t, err) | |
| 804 | + | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 805 | + | assert.NoError(t, err) | |
| 806 | + | } | |
| 807 | + | }) | |
| 808 | + | ||
| 809 | + | } | |
| 810 | + | ||
| 811 | + | func TestLoginRefusals(t *testing.T) { | |
| 812 | + | t.Parallel() | |
| 813 | + | ctx := context.Background() | |
| 814 | + | ||
| 815 | + | t.Run("unknown passkey", func(t *testing.T) { | |
| 816 | + | t.Parallel() | |
| 817 | + | f := newPasskeyFixture(t) | |
| 818 | + | f.acceptLogin("ghost", 1) | |
| 819 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 820 | + | require.NoError(t, err) | |
| 821 | + | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 822 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 823 | + | }) | |
| 824 | + | ||
| 825 | + | t.Run("verifier refuses", func(t *testing.T) { | |
| 826 | + | t.Parallel() | |
| 827 | + | f := newPasskeyFixture(t) | |
| 828 | + | f.add("cred-1") | |
| 829 | + | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything). | |
| 830 | + | Return(domain.Passkey{}, fmt.Errorf("%w: bad signature", domain.ErrUnauthenticated)) | |
| 831 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 832 | + | require.NoError(t, err) | |
| 833 | + | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 834 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 835 | + | assert.NotContains(t, err.Error(), "bad signature", "the reason is for logs, not for the caller") | |
| 836 | + | }) | |
| 837 | + | ||
| 838 | + | t.Run("store failure fails closed and is not an authentication error", func(t *testing.T) { | |
| 839 | + | t.Parallel() | |
| 840 | + | f := newPasskeyFixture(t) | |
| 841 | + | boom := errors.New("disk on fire") | |
| 842 | + | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything). | |
| 843 | + | Return(domain.Passkey{}, boom) | |
| 844 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 845 | + | require.NoError(t, err) | |
| 846 | + | token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 847 | + | require.ErrorIs(t, err, boom) | |
| 848 | + | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |
| 849 | + | assert.Empty(t, token) | |
| 850 | + | }) | |
| 851 | + | ||
| 852 | + | t.Run("update failure gives no session", func(t *testing.T) { | |
| 853 | + | t.Parallel() | |
| 854 | + | f := newPasskeyFixture(t) | |
| 855 | + | store := mocks.NewPasskeyStore(t) | |
| 856 | + | boom := errors.New("disk on fire") | |
| 857 | + | store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil) | |
| 858 | + | store.EXPECT().Get(mock.Anything, []byte("cred-1")).Return(domain.Passkey{ID: []byte("cred-1")}, nil) | |
| 859 | + | store.EXPECT().Update(mock.Anything, mock.Anything).Return(boom) | |
| 860 | + | verifier := mocks.NewPasskeyVerifier(t) | |
| 861 | + | verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("login-1"), nil) | |
| 862 | + | verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything). | |
| 863 | + | Return(domain.Passkey{ID: []byte("cred-1")}, nil) | |
| 864 | + | pk := usecase.NewPasskeys(store, verifier, f.sessions, f.clock) | |
| 865 | + | id, _, err := pk.BeginLogin(ctx, "192.0.2.1") | |
| 866 | + | require.NoError(t, err) | |
| 867 | + | token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`)) | |
| 868 | + | require.ErrorIs(t, err, boom) | |
| 869 | + | assert.Empty(t, token) | |
| 870 | + | }) | |
| 871 | + | ||
| 872 | + | t.Run("removed while verifying", func(t *testing.T) { | |
| 873 | + | t.Parallel() | |
| 874 | + | f := newPasskeyFixture(t) | |
| 875 | + | f.add("cred-1") | |
| 876 | + | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 877 | + | func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 878 | + | p, err := lookup([]byte("cred-1")) | |
| 879 | + | require.NoError(t, err) | |
| 880 | + | f.mu.Lock() | |
| 881 | + | delete(f.stored, "cred-1") // an owner removes it meanwhile | |
| 882 | + | f.mu.Unlock() | |
| 883 | + | return p, nil | |
| 884 | + | }) | |
| 885 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 886 | + | require.NoError(t, err) | |
| 887 | + | token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 888 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 889 | + | assert.Empty(t, token) | |
| 890 | + | }) | |
| 891 | + | } | |
| 892 | + | ||
| 893 | + | // Removing a passkey signs every browser out, the passkey's own sessions | |
| 894 | + | // included. | |
| 895 | + | func TestRemoveBumpsTheEpoch(t *testing.T) { | |
| 896 | + | t.Parallel() | |
| 897 | + | ctx := context.Background() | |
| 898 | + | f := newPasskeyFixture(t) | |
| 899 | + | f.add("cred-1") | |
| 900 | + | f.add("cred-2") | |
| 901 | + | f.acceptLogin("cred-1", 1) | |
| 902 | + | ||
| 903 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 904 | + | require.NoError(t, err) | |
| 905 | + | token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 906 | + | require.NoError(t, err) | |
| 907 | + | _, err = f.sessions.Verify(ctx, token) | |
| 908 | + | require.NoError(t, err) | |
| 909 | + | linkToken := func() string { | |
| 910 | + | tok, _, err := f.sessions.Login(ctx, f.nonce(t)) | |
| 911 | + | require.NoError(t, err) | |
| 912 | + | return tok | |
| 913 | + | }() | |
| 914 | + | ||
| 915 | + | require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("cred-2"))) | |
| 916 | + | assert.Equal(t, 1, f.count()) | |
| 917 | + | for name, tok := range map[string]string{"passkey session": token, "link session": linkToken} { | |
| 918 | + | _, err = f.sessions.Verify(ctx, tok) | |
| 919 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, name) | |
| 920 | + | } | |
| 921 | + | ||
| 922 | + | // A new login works with the epoch it finds now. | |
| 923 | + | id, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 924 | + | require.NoError(t, err) | |
| 925 | + | token, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 926 | + | require.NoError(t, err) | |
| 927 | + | _, err = f.sessions.Verify(ctx, token) | |
| 928 | + | assert.NoError(t, err) | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | func TestRemoveUnknownKeepsTheSessions(t *testing.T) { | |
| 932 | + | t.Parallel() | |
| 933 | + | ctx := context.Background() | |
| 934 | + | f := newPasskeyFixture(t) | |
| 935 | + | f.add("cred-1") | |
| 936 | + | tok, _, err := f.sessions.Login(ctx, f.nonce(t)) | |
| 937 | + | require.NoError(t, err) | |
| 938 | + | ||
| 939 | + | err = f.passkeys.Remove(ctx, domain.Owner(), []byte("ghost")) | |
| 940 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 941 | + | _, err = f.sessions.Verify(ctx, tok) | |
| 942 | + | assert.NoError(t, err, "nothing was removed, nobody is signed out") | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | func TestRemoveReportsASignOutFailure(t *testing.T) { | |
| 946 | + | t.Parallel() | |
| 947 | + | ctx := context.Background() | |
| 948 | + | boom := errors.New("disk on fire") | |
| 949 | + | sessionStore := mocks.NewSessionStore(t) | |
| 950 | + | sessionStore.EXPECT().Key(mock.Anything).Return(testHandle, nil) | |
| 951 | + | sessionStore.EXPECT().BumpEpoch(mock.Anything).Return(0, boom) | |
| 952 | + | clock := mocks.NewClock(t) | |
| 953 | + | clock.EXPECT().Now().Return(time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)).Maybe() | |
| 954 | + | sessions, err := usecase.NewSessions(ctx, sessionStore, clock, "https://forge.example") | |
| 955 | + | require.NoError(t, err) | |
| 956 | + | ||
| 957 | + | store := mocks.NewPasskeyStore(t) | |
| 958 | + | store.EXPECT().Delete(mock.Anything, []byte("cred-1")).Return(nil) | |
| 959 | + | pk := usecase.NewPasskeys(store, mocks.NewPasskeyVerifier(t), sessions, clock) | |
| 960 | + | err = pk.Remove(ctx, domain.Owner(), []byte("cred-1")) | |
| 961 | + | require.ErrorIs(t, err, boom) | |
| 962 | + | assert.Contains(t, err.Error(), "signing out the web sessions failed") | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | // The use cases are safe for concurrent callers (run with -race). | |
| 966 | + | func TestPasskeysConcurrent(t *testing.T) { | |
| 967 | + | t.Parallel() | |
| 968 | + | ctx := context.Background() | |
| 969 | + | f := newPasskeyFixture(t) | |
| 970 | + | f.add("cred-1") | |
| 971 | + | f.acceptLogin("cred-1", 0) // synced: counters racing in order would be refused, rightly | |
| 972 | + | var wg sync.WaitGroup | |
| 973 | + | for range 32 { | |
| 974 | + | wg.Add(1) | |
| 975 | + | go func() { | |
| 976 | + | defer wg.Done() | |
| 977 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 978 | + | if assert.NoError(t, err) { | |
| 979 | + | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 980 | + | assert.NoError(t, err) | |
| 981 | + | } | |
| 982 | + | _, _, _ = f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 983 | + | }() | |
| 984 | + | } | |
| 985 | + | wg.Wait() | |
| 986 | + | } | |
| 987 | + | ||
| 988 | + | // TS-001: a name that breaks the rules is refused before any ceremony starts, | |
| 989 | + | // and does not use up one of the pending registrations. | |
| 990 | + | func TestBeginRegistrationChecksTheName(t *testing.T) { | |
| 991 | + | t.Parallel() | |
| 992 | + | ctx := context.Background() | |
| 993 | + | f := newPasskeyFixture(t) | |
| 994 | + | for _, name := range []string{"evil \u202e name", "a\x00b", strings.Repeat("a", domain.MaxPasskeyNameLen+1), "two\nlines", "\xff"} { | |
| 995 | + | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), name) | |
| 996 | + | assert.ErrorIs(t, err, domain.ErrInvalidText, "%q", name) | |
| 997 | + | } | |
| 998 | + | // None of those counted as a pending registration. | |
| 999 | + | for range usecase.MaxPendingPasskeyRegistrations { | |
| 1000 | + | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "ok") | |
| 1001 | + | require.NoError(t, err) | |
| 1002 | + | } | |
| 1003 | + | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 1004 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 1005 | + | } | |
| 1006 | + | ||
| 1007 | + | // A client at its cap begins a login while the store is full. Its own eviction | |
| 1008 | + | // makes the room the new login needs, so exactly one ceremony is dropped, the | |
| 1009 | + | // client's own oldest: the global rule must not take a second one, from anyone. | |
| 1010 | + | func TestFullClientOnAFullStoreLosesOnlyItsOwnOldest(t *testing.T) { | |
| 1011 | + | t.Parallel() | |
| 1012 | + | f := newPasskeyFixture(t) | |
| 1013 | + | f.add("cred-1") | |
| 1014 | + | f.acceptLogin("cred-1", 1) | |
| 1015 | + | perClient := usecase.MaxPendingPasskeyLoginsPerClient | |
| 1016 | + | ||
| 1017 | + | // Seven clients with one login each, the flooder with a full cap, then full | |
| 1018 | + | // clients and a last partial one until the store holds exactly its cap. | |
| 1019 | + | small := make([]string, 0, 7) | |
| 1020 | + | for i := range 7 { | |
| 1021 | + | small = append(small, f.loginIDs(t, fmt.Sprintf("10.9.0.%d", i), 1)[0]) | |
| 1022 | + | } | |
| 1023 | + | flooder := f.loginIDs(t, "192.0.2.1", perClient) | |
| 1024 | + | total := len(small) + len(flooder) | |
| 1025 | + | var others []string | |
| 1026 | + | for n := 0; total+perClient <= usecase.MaxPendingPasskeyLogins; n++ { | |
| 1027 | + | others = append(others, f.loginIDs(t, fmt.Sprintf("10.0.%d.1", n), perClient)...) | |
| 1028 | + | total += perClient | |
| 1029 | + | } | |
| 1030 | + | if rest := usecase.MaxPendingPasskeyLogins - total; rest > 0 { | |
| 1031 | + | others = append(others, f.loginIDs(t, "10.8.0.1", rest)...) | |
| 1032 | + | total += rest | |
| 1033 | + | } | |
| 1034 | + | require.Equal(t, usecase.MaxPendingPasskeyLogins, total, "the store is full") | |
| 1035 | + | ||
| 1036 | + | newest := f.loginIDs(t, "192.0.2.1", 1)[0] | |
| 1037 | + | ||
| 1038 | + | assert.False(t, f.finishes(flooder[0]), "the flooder lost its own oldest") | |
| 1039 | + | for _, id := range append(append(flooder[1:], small...), others...) { | |
| 1040 | + | assert.True(t, f.finishes(id), "nobody else lost a ceremony") | |
| 1041 | + | } | |
| 1042 | + | assert.True(t, f.finishes(newest), "the new login works") | |
| 1043 | + | } | |
| 1044 | + | ||
| 1045 | + | // A failure of the store while removing a passkey is returned as it is, and | |
| 1046 | + | // nobody is signed out for it. | |
| 1047 | + | func TestRemoveReturnsAStoreError(t *testing.T) { | |
| 1048 | + | t.Parallel() | |
| 1049 | + | ctx := context.Background() | |
| 1050 | + | f := newPasskeyFixture(t) | |
| 1051 | + | boom := errors.New("disk on fire") | |
| 1052 | + | store := mocks.NewPasskeyStore(t) | |
| 1053 | + | store.EXPECT().Delete(mock.Anything, []byte("cred-1")).Return(boom) | |
| 1054 | + | // The session store is a strict mock: a BumpEpoch would fail the test. | |
| 1055 | + | pk := usecase.NewPasskeys(store, mocks.NewPasskeyVerifier(t), f.sessions, f.clock) | |
| 1056 | + | tok, _, err := f.sessions.Login(ctx, f.nonce(t)) | |
| 1057 | + | require.NoError(t, err) | |
| 1058 | + | ||
| 1059 | + | err = pk.Remove(ctx, domain.Owner(), []byte("cred-1")) | |
| 1060 | + | require.ErrorIs(t, err, boom) | |
| 1061 | + | assert.NotErrorIs(t, err, domain.ErrNotFound) | |
| 1062 | + | _, err = f.sessions.Verify(ctx, tok) | |
| 1063 | + | assert.NoError(t, err, "the epoch did not move: the sessions are still valid") | |
| 1064 | + | } | |
M internal/usecase/sessions.go +11 -0
| @@ -189,6 +189,17 @@ func (s *Sessions) issue(ctx context.Context, capAt time.Time) (string, time.Tim | |||
| 189 | 189 | return tok, exp.Truncate(time.Second), nil | |
| 190 | 190 | } | |
| 191 | 191 | ||
| 192 | + | // issueOwner opens an uncapped session for the owner (a passkey login): the | |
| 193 | + | // same token a login link gives, for SessionTTL. | |
| 194 | + | func (s *Sessions) issueOwner(ctx context.Context) (string, time.Time, error) { | |
| 195 | + | return s.issue(ctx, time.Time{}) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // bumpEpoch ends every session and returns the new epoch. | |
| 199 | + | func (s *Sessions) bumpEpoch(ctx context.Context) (uint64, error) { | |
| 200 | + | return s.store.BumpEpoch(ctx) | |
| 201 | + | } | |
| 202 | + | ||
| 192 | 203 | // Verify checks a session token and returns the owner. The signature must be | |
| 193 | 204 | // HS256 with the session key (the algorithm is pinned, so "none" and any other | |
| 194 | 205 | // are refused), the token must not be expired or issued in the future, and its | |
M proto/ermite/storage/session.proto +31 -0
| @@ -6,6 +6,8 @@ syntax = "proto3"; | |||
| 6 | 6 | ||
| 7 | 7 | package ermite.storage; | |
| 8 | 8 | ||
| 9 | + | import "google/protobuf/timestamp.proto"; | |
| 10 | + | ||
| 9 | 11 | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | |
| 10 | 12 | ||
| 11 | 13 | // SessionKey is stored under the key "session/key": the HS256 secret that | |
| @@ -22,3 +24,32 @@ message SessionKey { | |||
| 22 | 24 | message SessionEpoch { | |
| 23 | 25 | uint64 epoch = 1; | |
| 24 | 26 | } | |
| 27 | + | ||
| 28 | + | // PasskeyUser is stored under the key "passkey/user": the WebAuthn user | |
| 29 | + | // handle of the owner, generated on first use. It identifies the owner to | |
| 30 | + | // authenticators and never changes. | |
| 31 | + | message PasskeyUser { | |
| 32 | + | // 32 random bytes. | |
| 33 | + | bytes handle = 1; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Passkey is stored under the key "passkey/cred/<base64url credential id>": | |
| 37 | + | // a WebAuthn credential that can sign the owner in (ADR 0026). It holds the | |
| 38 | + | // public key only. | |
| 39 | + | message Passkey { | |
| 40 | + | // Credential id, at most 1023 bytes. | |
| 41 | + | bytes id = 1; | |
| 42 | + | // Name given by the owner. | |
| 43 | + | string name = 2; | |
| 44 | + | // COSE public key. | |
| 45 | + | bytes public_key = 3; | |
| 46 | + | string attestation_type = 4; | |
| 47 | + | repeated string transports = 5; | |
| 48 | + | // Last signature counter; 0 for synced passkeys. | |
| 49 | + | uint32 sign_count = 6; | |
| 50 | + | bool backup_eligible = 7; | |
| 51 | + | bool backup_state = 8; | |
| 52 | + | google.protobuf.Timestamp created_at = 9; | |
| 53 | + | // Unset until the passkey is first used. | |
| 54 | + | google.protobuf.Timestamp last_used_at = 10; | |
| 55 | + | } | |
M skills/ermite/CLI.md +4 -0
| @@ -84,3 +84,7 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, ` | |||
| 84 | 84 | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | |
| 85 | 85 | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret | | |
| 86 | 86 | | `web logout --all` | signs every browser out | | |
| 87 | + | | `web passkey list [--json]` | the owner's passkeys (`id`, `name`, `created_at`, `last_used_at`, `synced`); same rights as `web login`; fails with "passkeys are not available on this server" when the public URL is not https with a host name | | |
| 88 | + | | `web passkey remove <id>` | removes a passkey and signs every browser out; the id comes from `list` | | |
| 89 | + | ||
| 90 | + | Adding a passkey is a browser action (`/-/passkeys` once signed in); agents do not use passkeys. | |