better_auth-passkey 0.1.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +99 -1
- data/lib/better_auth/passkey/challenges.rb +46 -0
- data/lib/better_auth/passkey/credentials.rb +59 -0
- data/lib/better_auth/passkey/error_codes.rb +24 -0
- data/lib/better_auth/passkey/routes/authentication.rb +94 -0
- data/lib/better_auth/passkey/routes/management.rb +61 -0
- data/lib/better_auth/passkey/routes/registration.rb +93 -0
- data/lib/better_auth/passkey/routes.rb +5 -0
- data/lib/better_auth/passkey/schema.rb +57 -0
- data/lib/better_auth/passkey/utils.rb +174 -0
- data/lib/better_auth/passkey/version.rb +1 -1
- data/lib/better_auth/passkey.rb +6 -0
- data/lib/better_auth/plugins/passkey.rb +51 -369
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 509b37215dc04f3b6764922cb082261651d80b215d524b3af3fe03dffa1e046f
|
|
4
|
+
data.tar.gz: f5c58225f30016ced29b4f901d871ceac8c20c0620176961a6cf05880567935c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d5b4b313d7962247d9af939d3f8edb37e649c5183f219362608c19bcd397cb236e1095700ef0d27f2338212f6a1ba83d0d05c75adbea090a0403f543264404fe
|
|
7
|
+
data.tar.gz: 1c4bb5d2f5ac3cfb1245d59103128a29fb2457242c66d709e7fe08df8f396081bd5cf99a4e8cb0327479c7a57db210f1aadd4d018e525cd86d49280cee96ceda
|
data/CHANGELOG.md
CHANGED
|
@@ -2,4 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.0] - 2026-04-29
|
|
6
|
+
|
|
7
|
+
- Aligned passkey registration, authentication, verification, origin handling, credential metadata, and route behavior with upstream Better Auth v1.6.9.
|
|
8
|
+
- Expanded passkey documentation and test coverage for upstream server parity.
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-04-28
|
|
11
|
+
|
|
5
12
|
- Initial external passkey package extracted from `better_auth`.
|
data/README.md
CHANGED
|
@@ -17,11 +17,109 @@ auth = BetterAuth.auth(
|
|
|
17
17
|
secret: ENV.fetch("BETTER_AUTH_SECRET"),
|
|
18
18
|
database: :memory,
|
|
19
19
|
plugins: [
|
|
20
|
-
BetterAuth::Plugins.passkey
|
|
20
|
+
BetterAuth::Plugins.passkey(
|
|
21
|
+
rp_id: "localhost",
|
|
22
|
+
rp_name: "Example App",
|
|
23
|
+
origin: "http://localhost:3000"
|
|
24
|
+
)
|
|
21
25
|
]
|
|
22
26
|
)
|
|
23
27
|
```
|
|
24
28
|
|
|
29
|
+
## Options
|
|
30
|
+
|
|
31
|
+
`BetterAuth::Plugins.passkey` accepts Ruby `snake_case` options:
|
|
32
|
+
|
|
33
|
+
- `rp_id`: WebAuthn relying party ID. Defaults to the configured `base_url` host.
|
|
34
|
+
- `rp_name`: WebAuthn relying party name. Defaults to the Better Auth app name.
|
|
35
|
+
- `origin`: allowed WebAuthn origin or array of origins.
|
|
36
|
+
- `authenticator_selection`: supports `resident_key`, `user_verification`, and `authenticator_attachment`.
|
|
37
|
+
- `advanced.web_authn_challenge_cookie`: challenge cookie name. Defaults to `better-auth-passkey`.
|
|
38
|
+
- `registration`: supports `require_session`, `resolve_user`, `after_verification`, and `extensions`.
|
|
39
|
+
- `authentication`: supports `after_verification` and `extensions`.
|
|
40
|
+
- `schema`: deep-merged schema overrides. The built-in SQL table remains `passkeys`, matching the Ruby adapter convention.
|
|
41
|
+
|
|
42
|
+
HTTP routes and wire JSON keys are kept compatible with upstream Better Auth passkey server behavior. Ruby method names and configuration keys remain idiomatic `snake_case`.
|
|
43
|
+
|
|
44
|
+
## Passkey-first registration
|
|
45
|
+
|
|
46
|
+
Use `require_session: false` to register a passkey before a session exists:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
BetterAuth::Plugins.passkey(
|
|
50
|
+
registration: {
|
|
51
|
+
require_session: false,
|
|
52
|
+
resolve_user: lambda do |data|
|
|
53
|
+
invitation = Invitations.verify!(data.fetch(:context))
|
|
54
|
+
{
|
|
55
|
+
id: invitation.user_id,
|
|
56
|
+
name: invitation.email,
|
|
57
|
+
display_name: invitation.name,
|
|
58
|
+
email: invitation.email
|
|
59
|
+
}
|
|
60
|
+
end,
|
|
61
|
+
after_verification: lambda do |data|
|
|
62
|
+
Audit.passkey_registered!(
|
|
63
|
+
user_id: data.fetch(:user).fetch(:id),
|
|
64
|
+
context: data.fetch(:context)
|
|
65
|
+
)
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Pass `context` when generating registration options:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
auth.api.generate_passkey_registration_options(query: { context: invitation_token })
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
During passkey-first registration, `after_verification` may return `{ user_id: "..." }` to attach the credential to a concrete user. During session-required registration, switching users is rejected.
|
|
79
|
+
|
|
80
|
+
## Callback contracts
|
|
81
|
+
|
|
82
|
+
Ruby uses hashes for the upstream TypeScript contracts:
|
|
83
|
+
|
|
84
|
+
- Stored challenge value: `expectedChallenge`, `userData.id`, optional `userData.name`, optional `userData.displayName`, and optional `context`.
|
|
85
|
+
- `resolve_user` receives `{ ctx:, context: }` and must return at least `id` and `name`; it may also return `display_name` and `email`.
|
|
86
|
+
- Registration `after_verification` receives `{ ctx:, verification:, user:, client_data:, context: }`.
|
|
87
|
+
- Authentication `after_verification` receives `{ ctx:, verification:, client_data: }`.
|
|
88
|
+
- Passkey records use upstream wire keys including `userId`, `credentialID`, `publicKey`, `deviceType`, `backedUp`, `createdAt`, and optional `aaguid`.
|
|
89
|
+
|
|
90
|
+
## WebAuthn extensions
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
BetterAuth::Plugins.passkey(
|
|
94
|
+
registration: {
|
|
95
|
+
extensions: { credProps: true }
|
|
96
|
+
},
|
|
97
|
+
authentication: {
|
|
98
|
+
extensions: ->(_data) { { hmacGetSecret: true } }
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Browser client scope
|
|
104
|
+
|
|
105
|
+
This gem provides server WebAuthn routes. It does not ship the upstream browser-only `@better-auth/passkey/client` helper, `passkeyClient`, `startRegistration`, `startAuthentication`, conditional UI, autofill, or extension-result handling. Use the browser WebAuthn APIs directly or wrap them in application JavaScript.
|
|
106
|
+
|
|
107
|
+
## WebAuthn configuration
|
|
108
|
+
|
|
109
|
+
The plugin uses `WebAuthn::RelyingParty` per request for `rp_id`, `rp_name`, and allowed origins. It does not mutate global `WebAuthn.configuration`, so multiple Better Auth instances can use different relying-party settings in the same Ruby process.
|
|
110
|
+
|
|
111
|
+
## Upstream parity notes
|
|
112
|
+
|
|
113
|
+
The Ruby plugin tracks Better Auth `v1.6.9` upstream behavior. A few wire-shape and validation details are worth noting:
|
|
114
|
+
|
|
115
|
+
- `excludeCredentials` entries (registration options) are emitted as `{id, transports?}` to match upstream's `@simplewebauthn/server` output. `allowCredentials` (authentication options) still includes `type: "public-key"` to mirror upstream's authentication wire shape.
|
|
116
|
+
- `transports` is omitted entirely from credential descriptors when the stored value is missing or empty (rather than emitting an empty array).
|
|
117
|
+
- The default storage table is named `passkeys` (plural) in the SQL adapters, mapped from the upstream `passkey` model. Custom SQL adapters that translate the `passkey` model name continue to work.
|
|
118
|
+
- `rp_id` resolution falls back to `URI.parse(base_url).host` (port stripped). When `base_url` is empty or unparseable, `rp_id` defaults to `"localhost"`.
|
|
119
|
+
- For passkey-first registration, the `after_verification` callback may return `{ user_id: nil }` or `{ user_id: "" }` to leave the resolved user unchanged. Returning any other non-empty-string value (integer, boolean, etc.) raises `RESOLVED_USER_INVALID`.
|
|
120
|
+
- `update_passkey` accepts an empty-string `name` to match upstream `z.string()`. Missing or non-string `name` still raises `VALIDATION_ERROR`.
|
|
121
|
+
- Cross-user `delete_passkey` raises `UNAUTHORIZED` with the `PASSKEY_NOT_FOUND` message, mirroring upstream's `requireResourceOwnership` middleware behavior when only `notFoundError` is configured.
|
|
122
|
+
|
|
25
123
|
## Notes
|
|
26
124
|
|
|
27
125
|
This package depends on the maintained `webauthn` gem. Keeping passkeys outside `better_auth` avoids installing WebAuthn dependencies for applications that do not use passkeys.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module Passkey
|
|
7
|
+
module Challenges
|
|
8
|
+
CHALLENGE_MAX_AGE = 60 * 5
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def store_challenge(ctx, config, challenge, user_id)
|
|
13
|
+
user_data = user_id.is_a?(Hash) ? user_id : {id: user_id}
|
|
14
|
+
verification_token = Crypto.random_string(32)
|
|
15
|
+
cookie = challenge_cookie(ctx, config)
|
|
16
|
+
ctx.set_signed_cookie(cookie.name, verification_token, ctx.context.secret, cookie.attributes.merge(max_age: CHALLENGE_MAX_AGE))
|
|
17
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
18
|
+
identifier: verification_token,
|
|
19
|
+
value: JSON.generate({
|
|
20
|
+
expectedChallenge: challenge,
|
|
21
|
+
userData: user_data,
|
|
22
|
+
context: BetterAuth::Passkey::Utils.normalize_hash(ctx.query)[:context]
|
|
23
|
+
}),
|
|
24
|
+
expiresAt: Time.now + CHALLENGE_MAX_AGE
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def find_challenge(ctx, verification_token)
|
|
29
|
+
verification = ctx.context.internal_adapter.find_verification_value(verification_token)
|
|
30
|
+
return nil if verification.nil? || BetterAuth::Routes.expired_time?(verification["expiresAt"] || verification[:expiresAt])
|
|
31
|
+
|
|
32
|
+
JSON.parse(verification.fetch("value") { verification.fetch(:value) })
|
|
33
|
+
rescue JSON::ParserError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def challenge_token(ctx, config)
|
|
38
|
+
ctx.get_signed_cookie(challenge_cookie(ctx, config).name, ctx.context.secret)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def challenge_cookie(ctx, config)
|
|
42
|
+
ctx.context.create_auth_cookie(config.dig(:advanced, :web_authn_challenge_cookie), max_age: CHALLENGE_MAX_AGE)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Passkey
|
|
5
|
+
module Credentials
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def webauthn_response(value)
|
|
9
|
+
data = BetterAuth::Passkey::Utils.normalize_hash(value || {})
|
|
10
|
+
response = BetterAuth::Passkey::Utils.normalize_hash(data[:response] || {})
|
|
11
|
+
webauthn = {
|
|
12
|
+
"type" => data[:type],
|
|
13
|
+
"id" => data[:id],
|
|
14
|
+
"rawId" => data[:raw_id],
|
|
15
|
+
"authenticatorAttachment" => data[:authenticator_attachment],
|
|
16
|
+
"clientExtensionResults" => data[:client_extension_results] || {},
|
|
17
|
+
"response" => {
|
|
18
|
+
"attestationObject" => response[:attestation_object],
|
|
19
|
+
"clientDataJSON" => response[:client_data_json],
|
|
20
|
+
"transports" => response[:transports],
|
|
21
|
+
"authenticatorData" => response[:authenticator_data],
|
|
22
|
+
"signature" => response[:signature],
|
|
23
|
+
"userHandle" => response[:user_handle]
|
|
24
|
+
}.compact
|
|
25
|
+
}.compact
|
|
26
|
+
webauthn["rawId"] ||= webauthn["id"]
|
|
27
|
+
webauthn
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def attestation_response(credential)
|
|
31
|
+
credential.instance_variable_get(:@response)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def authenticator_data(credential)
|
|
35
|
+
attestation_response(credential)&.authenticator_data
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def wire(record)
|
|
39
|
+
return record unless record.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
output = record.dup
|
|
42
|
+
output["credentialID"] = output.delete("credentialId") if output.key?("credentialId")
|
|
43
|
+
output
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def credential_id(record)
|
|
47
|
+
record["credentialID"] || record["credentialId"] || record[:credentialID] || record[:credential_id]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def credential_descriptor(record, kind: :allow)
|
|
51
|
+
descriptor = {id: credential_id(record)}
|
|
52
|
+
descriptor[:type] = "public-key" if kind == :allow
|
|
53
|
+
transports = (record["transports"] || record[:transports]).to_s.split(",").map(&:strip).reject(&:empty?)
|
|
54
|
+
descriptor[:transports] = transports if transports.any?
|
|
55
|
+
descriptor
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Passkey
|
|
5
|
+
module ErrorCodes
|
|
6
|
+
PASSKEY_ERROR_CODES = {
|
|
7
|
+
"CHALLENGE_NOT_FOUND" => "Challenge not found",
|
|
8
|
+
"YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY" => "You are not allowed to register this passkey",
|
|
9
|
+
"FAILED_TO_VERIFY_REGISTRATION" => "Failed to verify registration",
|
|
10
|
+
"PASSKEY_NOT_FOUND" => "Passkey not found",
|
|
11
|
+
"AUTHENTICATION_FAILED" => "Authentication failed",
|
|
12
|
+
"UNABLE_TO_CREATE_SESSION" => "Unable to create session",
|
|
13
|
+
"FAILED_TO_UPDATE_PASSKEY" => "Failed to update passkey",
|
|
14
|
+
"PREVIOUSLY_REGISTERED" => "Previously registered",
|
|
15
|
+
"REGISTRATION_CANCELLED" => "Registration cancelled",
|
|
16
|
+
"AUTH_CANCELLED" => "Auth cancelled",
|
|
17
|
+
"UNKNOWN_ERROR" => "Unknown error",
|
|
18
|
+
"SESSION_REQUIRED" => "Passkey registration requires an authenticated session",
|
|
19
|
+
"RESOLVE_USER_REQUIRED" => "Passkey registration requires either an authenticated session or a resolveUser callback when requireSession is false",
|
|
20
|
+
"RESOLVED_USER_INVALID" => "Resolved user is invalid"
|
|
21
|
+
}.freeze
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "webauthn"
|
|
5
|
+
|
|
6
|
+
module BetterAuth
|
|
7
|
+
module Passkey
|
|
8
|
+
module Routes
|
|
9
|
+
module Authentication
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def generate_passkey_authentication_options_endpoint(config)
|
|
13
|
+
Endpoint.new(path: "/passkey/generate-authenticate-options", method: "GET") do |ctx|
|
|
14
|
+
session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
|
|
15
|
+
relying_party = Utils.relying_party(config, ctx)
|
|
16
|
+
passkeys = if session
|
|
17
|
+
ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
|
|
18
|
+
else
|
|
19
|
+
[]
|
|
20
|
+
end
|
|
21
|
+
get_options = {
|
|
22
|
+
extensions: Utils.resolve_extensions(config.dig(:authentication, :extensions), ctx),
|
|
23
|
+
relying_party: relying_party
|
|
24
|
+
}
|
|
25
|
+
get_options[:allow] = passkeys.map { |passkey| Credentials.credential_id(passkey) } if passkeys.any?
|
|
26
|
+
options = WebAuthn::Credential.options_for_get(**get_options)
|
|
27
|
+
Challenges.store_challenge(ctx, config, options.challenge, session ? session.fetch(:user).fetch("id") : "")
|
|
28
|
+
payload = options.as_json.merge(userVerification: "preferred")
|
|
29
|
+
payload.delete(:extensions) if payload[:extensions].nil? || payload[:extensions] == {}
|
|
30
|
+
payload.delete("extensions") if payload["extensions"].nil? || payload["extensions"] == {}
|
|
31
|
+
if passkeys.any?
|
|
32
|
+
payload[:allowCredentials] = passkeys.map { |passkey| Credentials.credential_descriptor(passkey) }
|
|
33
|
+
else
|
|
34
|
+
payload.delete(:allowCredentials)
|
|
35
|
+
payload.delete("allowCredentials")
|
|
36
|
+
end
|
|
37
|
+
ctx.json(payload)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def verify_passkey_authentication_endpoint(config)
|
|
42
|
+
Endpoint.new(path: "/passkey/verify-authentication", method: "POST") do |ctx|
|
|
43
|
+
body = Utils.normalize_hash(ctx.body)
|
|
44
|
+
Utils.require_key!(body, :response)
|
|
45
|
+
origin = Utils.origin(config, ctx)
|
|
46
|
+
raise APIError.new("BAD_REQUEST", message: "origin missing") if origin.to_s.empty?
|
|
47
|
+
|
|
48
|
+
verification_token = Challenges.challenge_token(ctx, config)
|
|
49
|
+
raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless verification_token
|
|
50
|
+
|
|
51
|
+
challenge = Challenges.find_challenge(ctx, verification_token)
|
|
52
|
+
raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless challenge
|
|
53
|
+
|
|
54
|
+
response = Credentials.webauthn_response(body[:response])
|
|
55
|
+
credential_id = response.fetch("id")
|
|
56
|
+
passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "credentialID", value: credential_id}])
|
|
57
|
+
raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
|
|
58
|
+
|
|
59
|
+
relying_party = Utils.relying_party(config, ctx, origin: origin)
|
|
60
|
+
credential = WebAuthn::Credential.from_get(response, relying_party: relying_party)
|
|
61
|
+
credential.verify(
|
|
62
|
+
challenge.fetch("expectedChallenge"),
|
|
63
|
+
public_key: Base64.strict_decode64(passkey.fetch("publicKey")),
|
|
64
|
+
sign_count: passkey.fetch("counter").to_i,
|
|
65
|
+
user_verification: false
|
|
66
|
+
)
|
|
67
|
+
Utils.call_callback(config.dig(:authentication, :after_verification), {
|
|
68
|
+
ctx: ctx,
|
|
69
|
+
verification: credential,
|
|
70
|
+
client_data: response
|
|
71
|
+
})
|
|
72
|
+
ctx.context.adapter.update(
|
|
73
|
+
model: "passkey",
|
|
74
|
+
where: [{field: "id", value: passkey.fetch("id")}],
|
|
75
|
+
update: {counter: credential.sign_count}
|
|
76
|
+
)
|
|
77
|
+
session = ctx.context.internal_adapter.create_session(passkey.fetch("userId"))
|
|
78
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("UNABLE_TO_CREATE_SESSION")) unless session
|
|
79
|
+
|
|
80
|
+
user = ctx.context.internal_adapter.find_user_by_id(passkey.fetch("userId"))
|
|
81
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: "User not found") unless user
|
|
82
|
+
|
|
83
|
+
Cookies.set_session_cookie(ctx, {session: session, user: user})
|
|
84
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
|
|
85
|
+
ctx.json({session: session, user: user})
|
|
86
|
+
rescue WebAuthn::Error, ArgumentError => error
|
|
87
|
+
ctx.context.logger&.error("Failed to verify authentication", error)
|
|
88
|
+
raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("AUTHENTICATION_FAILED"))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Passkey
|
|
5
|
+
module Routes
|
|
6
|
+
module Management
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def list_passkeys_endpoint
|
|
10
|
+
Endpoint.new(path: "/passkey/list-user-passkeys", method: "GET") do |ctx|
|
|
11
|
+
session = BetterAuth::Routes.current_session(ctx)
|
|
12
|
+
passkeys = ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
|
|
13
|
+
ctx.json(passkeys.map { |passkey| Credentials.wire(passkey) })
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def delete_passkey_endpoint
|
|
18
|
+
Endpoint.new(path: "/passkey/delete-passkey", method: "POST") do |ctx|
|
|
19
|
+
session = BetterAuth::Routes.current_session(ctx)
|
|
20
|
+
body = Utils.normalize_hash(ctx.body)
|
|
21
|
+
Utils.require_string!(body, :id)
|
|
22
|
+
passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "id", value: body[:id]}])
|
|
23
|
+
raise APIError.new("NOT_FOUND", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
|
|
24
|
+
unless passkey.fetch("userId") == session.fetch(:user).fetch("id")
|
|
25
|
+
raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
ctx.context.adapter.delete(model: "passkey", where: [{field: "id", value: passkey.fetch("id")}])
|
|
29
|
+
ctx.json({status: true})
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def update_passkey_endpoint
|
|
34
|
+
Endpoint.new(path: "/passkey/update-passkey", method: "POST") do |ctx|
|
|
35
|
+
session = BetterAuth::Routes.current_session(ctx)
|
|
36
|
+
body = Utils.normalize_hash(ctx.body)
|
|
37
|
+
Utils.require_string!(body, :id)
|
|
38
|
+
unless body.key?(:name) && body[:name].is_a?(String)
|
|
39
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("VALIDATION_ERROR"))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "id", value: body[:id]}])
|
|
43
|
+
raise APIError.new("NOT_FOUND", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
|
|
44
|
+
if passkey.fetch("userId") != session.fetch(:user).fetch("id")
|
|
45
|
+
raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
updated = ctx.context.adapter.update(
|
|
49
|
+
model: "passkey",
|
|
50
|
+
where: [{field: "id", value: body[:id]}],
|
|
51
|
+
update: {name: body[:name].to_s}
|
|
52
|
+
)
|
|
53
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("FAILED_TO_UPDATE_PASSKEY")) unless updated
|
|
54
|
+
|
|
55
|
+
ctx.json({passkey: Credentials.wire(updated)})
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "webauthn"
|
|
5
|
+
|
|
6
|
+
module BetterAuth
|
|
7
|
+
module Passkey
|
|
8
|
+
module Routes
|
|
9
|
+
module Registration
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def generate_passkey_registration_options_endpoint(config)
|
|
13
|
+
Endpoint.new(path: "/passkey/generate-register-options", method: "GET") do |ctx|
|
|
14
|
+
query = Utils.normalize_hash(ctx.query)
|
|
15
|
+
Utils.validate_authenticator_attachment!(query[:authenticator_attachment])
|
|
16
|
+
user = Utils.resolve_registration_user(config, ctx, query)
|
|
17
|
+
relying_party = Utils.relying_party(config, ctx)
|
|
18
|
+
existing = ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: user.fetch("id")}])
|
|
19
|
+
options = WebAuthn::Credential.options_for_create(
|
|
20
|
+
user: {
|
|
21
|
+
id: Crypto.random_string(32).downcase,
|
|
22
|
+
name: query[:name].to_s.empty? ? (user["email"] || user["name"] || user["id"]) : query[:name].to_s,
|
|
23
|
+
display_name: user["displayName"] || user["display_name"] || user["email"] || user["name"] || user["id"]
|
|
24
|
+
},
|
|
25
|
+
exclude: existing.map { |passkey| Credentials.credential_id(passkey) },
|
|
26
|
+
authenticator_selection: Utils.authenticator_selection(config, query),
|
|
27
|
+
extensions: Utils.resolve_extensions(config.dig(:registration, :extensions), ctx),
|
|
28
|
+
relying_party: relying_party
|
|
29
|
+
)
|
|
30
|
+
Challenges.store_challenge(ctx, config, options.challenge, {
|
|
31
|
+
id: user.fetch("id"),
|
|
32
|
+
name: user["name"] || user["email"] || user["id"],
|
|
33
|
+
displayName: user["displayName"] || user["display_name"]
|
|
34
|
+
}.compact)
|
|
35
|
+
payload = options.as_json.merge(attestation: "none", excludeCredentials: existing.map { |passkey| Credentials.credential_descriptor(passkey, kind: :exclude) })
|
|
36
|
+
payload.delete(:extensions) if payload[:extensions].nil? || payload[:extensions] == {}
|
|
37
|
+
payload.delete("extensions") if payload["extensions"].nil? || payload["extensions"] == {}
|
|
38
|
+
ctx.json(payload)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def verify_passkey_registration_endpoint(config)
|
|
43
|
+
Endpoint.new(path: "/passkey/verify-registration", method: "POST") do |ctx|
|
|
44
|
+
body = Utils.normalize_hash(ctx.body)
|
|
45
|
+
Utils.require_key!(body, :response)
|
|
46
|
+
require_session = config.dig(:registration, :require_session) != false
|
|
47
|
+
session = require_session ? BetterAuth::Routes.current_session(ctx, sensitive: true) : BetterAuth::Routes.current_session(ctx, allow_nil: true)
|
|
48
|
+
origin = Utils.origin(config, ctx)
|
|
49
|
+
raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION")) if origin.to_s.empty?
|
|
50
|
+
|
|
51
|
+
verification_token = Challenges.challenge_token(ctx, config)
|
|
52
|
+
raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless verification_token
|
|
53
|
+
|
|
54
|
+
challenge = Challenges.find_challenge(ctx, verification_token)
|
|
55
|
+
unless challenge
|
|
56
|
+
raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND"))
|
|
57
|
+
end
|
|
58
|
+
if session && challenge.fetch("userData").fetch("id") != session.fetch(:user).fetch("id")
|
|
59
|
+
raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
response = Credentials.webauthn_response(body[:response])
|
|
63
|
+
relying_party = Utils.relying_party(config, ctx, origin: origin)
|
|
64
|
+
credential = WebAuthn::Credential.from_create(response, relying_party: relying_party)
|
|
65
|
+
credential.verify(challenge.fetch("expectedChallenge"), user_verification: false)
|
|
66
|
+
authenticator_data = Credentials.authenticator_data(credential)
|
|
67
|
+
target_user_id = Utils.after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
|
|
68
|
+
data = ctx.context.adapter.create(
|
|
69
|
+
model: "passkey",
|
|
70
|
+
data: {
|
|
71
|
+
name: body[:name],
|
|
72
|
+
userId: target_user_id,
|
|
73
|
+
credentialID: credential.id,
|
|
74
|
+
publicKey: Base64.strict_encode64(credential.public_key),
|
|
75
|
+
counter: credential.sign_count,
|
|
76
|
+
deviceType: authenticator_data&.credential_backup_eligible? ? "multiDevice" : "singleDevice",
|
|
77
|
+
backedUp: authenticator_data&.credential_backed_up? || false,
|
|
78
|
+
transports: Array(Credentials.attestation_response(credential)&.transports).join(","),
|
|
79
|
+
createdAt: Time.now,
|
|
80
|
+
aaguid: Credentials.attestation_response(credential)&.aaguid
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
|
|
84
|
+
ctx.json(Credentials.wire(data))
|
|
85
|
+
rescue WebAuthn::Error => error
|
|
86
|
+
ctx.context.logger&.error("Failed to verify registration", error)
|
|
87
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION"))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Passkey
|
|
5
|
+
module Schema
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def passkey_schema(custom_schema = nil)
|
|
9
|
+
base = {
|
|
10
|
+
passkey: {
|
|
11
|
+
model_name: "passkeys",
|
|
12
|
+
fields: {
|
|
13
|
+
name: {type: "string", required: false},
|
|
14
|
+
publicKey: {type: "string", required: true},
|
|
15
|
+
userId: {type: "string", references: {model: "user", field: "id"}, required: true, index: true},
|
|
16
|
+
credentialID: {type: "string", required: true, index: true},
|
|
17
|
+
counter: {type: "number", required: true},
|
|
18
|
+
deviceType: {type: "string", required: true},
|
|
19
|
+
backedUp: {type: "boolean", required: true},
|
|
20
|
+
transports: {type: "string", required: false},
|
|
21
|
+
createdAt: {type: "date", required: false},
|
|
22
|
+
aaguid: {type: "string", required: false}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
deep_merge_hashes(normalize_hash(base), normalize_hash(custom_schema || {}))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def deep_merge_hashes(base, override)
|
|
30
|
+
base.merge(override) do |_key, old_value, new_value|
|
|
31
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
|
32
|
+
deep_merge_hashes(old_value, new_value)
|
|
33
|
+
else
|
|
34
|
+
new_value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalize_hash(value)
|
|
40
|
+
return {} unless value.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
value.each_with_object({}) do |(key, object), result|
|
|
43
|
+
result[normalize_key(key)] = object.is_a?(Hash) ? normalize_hash(object) : object
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_key(key)
|
|
48
|
+
key.to_s
|
|
49
|
+
.delete_prefix("$")
|
|
50
|
+
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
51
|
+
.tr("-", "_")
|
|
52
|
+
.downcase
|
|
53
|
+
.to_sym
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|