persona-protocol 0.1.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 +7 -0
- data/AUTHORS +6 -0
- data/LICENSE +21 -0
- data/lib/persona/account_link.rb +98 -0
- data/lib/persona/claim_code.rb +36 -0
- data/lib/persona/oidc/link_store.rb +100 -0
- data/lib/persona/oidc/verifier.rb +222 -0
- data/lib/persona/oidc.rb +59 -0
- data/lib/persona/version.rb +3 -0
- data/lib/persona.rb +147 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3c3d3ff355f856b7467643bf5e39a108d11be6d4e8f471e01203b1b9059836d4
|
|
4
|
+
data.tar.gz: 6d30790a52861e0f69f6bc0d21d1807d56de740236cf61db336d12e96d423ea2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3b6a984f0354d4c0f0079921b32ab9a7a5c6e1fdc2cb4f10c790ca85dd06ef294e248937407b615a2b6ee96958adeb4db81c8f6806c003ba06b34d21e3dbd098
|
|
7
|
+
data.tar.gz: fee2f202019c0341c2f3ef0064b3d851a8e83b1938eb6f21df3b986ba69eedf437792fd15905d072cc975ce5ddda39749f7711edc7ce545a329c9e28c6019f95
|
data/AUTHORS
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UNIBA COMMONS Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module Persona
|
|
2
|
+
# Binds a verified OIDC identity (provider + subject) to a persona.
|
|
3
|
+
#
|
|
4
|
+
# Structurally the same event as redeeming a recovery code: the subject
|
|
5
|
+
# identifies a "holder" — the persona already bound to it, if any — and the
|
|
6
|
+
# acting browser is either adopted into that holder or has the subject
|
|
7
|
+
# linked to it. When the browser is already a *different* joined persona
|
|
8
|
+
# with contributions, the two are folded together via the store's merge,
|
|
9
|
+
# gated by a confirmation preview.
|
|
10
|
+
#
|
|
11
|
+
# Callers pass the browser's agent_uid so an anonymous browser can be
|
|
12
|
+
# bound; current_user (resolved from the X-Agent-Id header or the session
|
|
13
|
+
# cookie, depending on transport) is the persona the browser is currently
|
|
14
|
+
# acting as, or nil when still anonymous.
|
|
15
|
+
#
|
|
16
|
+
# All persistence goes through a storage port so the decision logic stays
|
|
17
|
+
# host-agnostic. The port is an object implementing:
|
|
18
|
+
#
|
|
19
|
+
# holder_for(provider:, subject:) -> user | nil
|
|
20
|
+
# within_transaction { ... } atomic scope; yields
|
|
21
|
+
# create_guest!(agent_uid:, user_agent:) -> user, already bound to
|
|
22
|
+
# agent_uid, with the app's
|
|
23
|
+
# usual join side effects run
|
|
24
|
+
# add_account_binding!(user, provider:, subject:)
|
|
25
|
+
# agent_binding?(user, agent_uid:) -> boolean
|
|
26
|
+
# add_agent_binding!(user, agent_uid:, user_agent:)
|
|
27
|
+
# merge_preview(source:, target:) -> opaque preview object,
|
|
28
|
+
# MUST NOT change data
|
|
29
|
+
# merge!(source:, target:) move all bindings and domain
|
|
30
|
+
# records from source to
|
|
31
|
+
# target, then retire source
|
|
32
|
+
#
|
|
33
|
+
# User objects are the host's own; the only requirement is a stable #id.
|
|
34
|
+
# Consumers register their implementation via
|
|
35
|
+
# Persona.config.account_link_store (an ActiveRecord example lives in
|
|
36
|
+
# examples/ at the repository root).
|
|
37
|
+
module AccountLink
|
|
38
|
+
# agent_uid is set only when the browser must persist a (new) one via
|
|
39
|
+
# setAgentId — i.e. the anonymous cases. merge_preview is present only
|
|
40
|
+
# when confirmation is required, in which case NOTHING was changed.
|
|
41
|
+
Result = Struct.new(:user, :agent_uid, :merged, :merge_preview, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def perform(identity:, current_user:, agent_uid:, user_agent: nil, confirm_merge: false,
|
|
46
|
+
store: Persona.config.account_link_store)
|
|
47
|
+
raise ConfigurationError, 'Persona.config.account_link_store is not configured — ' \
|
|
48
|
+
'provide an object implementing the AccountLink storage port' if store.nil?
|
|
49
|
+
|
|
50
|
+
holder = holder_for(identity, store: store)
|
|
51
|
+
|
|
52
|
+
# Conflict peek: the browser is joined as someone other than the
|
|
53
|
+
# subject's current holder. Return the preview without touching data so
|
|
54
|
+
# the user can still cancel; the client re-sends with confirm_merge.
|
|
55
|
+
if current_user && holder && current_user.id != holder.id && !confirm_merge
|
|
56
|
+
return Result.new(
|
|
57
|
+
user: current_user,
|
|
58
|
+
agent_uid: nil,
|
|
59
|
+
merged: false,
|
|
60
|
+
merge_preview: store.merge_preview(source: current_user, target: holder),
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
store.within_transaction do
|
|
65
|
+
# Subject not yet linked to any persona: bind it to the acting
|
|
66
|
+
# persona, creating a fresh guest first if the browser is anonymous.
|
|
67
|
+
if holder.nil?
|
|
68
|
+
holder = current_user || store.create_guest!(agent_uid: agent_uid, user_agent: user_agent)
|
|
69
|
+
store.add_account_binding!(holder, provider: identity.provider, subject: identity.subject)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
merged = false
|
|
73
|
+
if current_user && current_user.id != holder.id
|
|
74
|
+
store.merge!(source: current_user, target: holder)
|
|
75
|
+
merged = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Ensure this browser's agent_uid resolves to the holder. For a joined
|
|
79
|
+
# browser that was just merged, merge! already moved its binding; for
|
|
80
|
+
# an anonymous browser adopting an existing holder, add it now.
|
|
81
|
+
unless store.agent_binding?(holder, agent_uid: agent_uid)
|
|
82
|
+
store.add_agent_binding!(holder, agent_uid: agent_uid, user_agent: user_agent)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Result.new(
|
|
86
|
+
user: holder,
|
|
87
|
+
agent_uid: current_user ? nil : agent_uid,
|
|
88
|
+
merged: merged,
|
|
89
|
+
merge_preview: nil,
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def holder_for(identity, store: Persona.config.account_link_store)
|
|
95
|
+
store.holder_for(provider: identity.provider, subject: identity.subject)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
require 'securerandom'
|
|
3
|
+
|
|
4
|
+
module Persona
|
|
5
|
+
# Single-use claim codes (docs/spec §6): generation, input
|
|
6
|
+
# normalization, display formatting, and digest-at-rest. Codes use
|
|
7
|
+
# Crockford base32 (no I, L, O, U) so they survive being read aloud or
|
|
8
|
+
# typed. Storage keeps only the digest; consumption is the consumer's
|
|
9
|
+
# concern (single-use, atomic — P-7).
|
|
10
|
+
module ClaimCode
|
|
11
|
+
ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# 16 chars ≈ 80 bits — fine for a short-lived, single-use code. Raise
|
|
16
|
+
# the length for codes expected to sit unused for long periods.
|
|
17
|
+
def generate(length: 16)
|
|
18
|
+
Array.new(length) { ALPHABET[SecureRandom.random_number(ALPHABET.length)] }.join
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Accept a pasted code in any spacing or case.
|
|
22
|
+
def normalize(input)
|
|
23
|
+
input.to_s.upcase.gsub(/[^0-9A-Z]/, '')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Group into 4-char blocks for display: "3F7K-9B2D-…".
|
|
27
|
+
def format(code)
|
|
28
|
+
normalize(code).scan(/.{1,4}/).join('-')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# What gets persisted instead of the code (P-7).
|
|
32
|
+
def digest(code)
|
|
33
|
+
Digest::SHA256.hexdigest(normalize(code))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'securerandom'
|
|
3
|
+
|
|
4
|
+
module Persona
|
|
5
|
+
module Oidc
|
|
6
|
+
# Short-lived Redis storage that ties the OIDC redirect round-trip to the
|
|
7
|
+
# browser that started it — without ever putting the agent_uid in a URL.
|
|
8
|
+
#
|
|
9
|
+
# begin : put_pending(state, agent_uid:, provider:)
|
|
10
|
+
# /auth/oidc/callback : take_pending(state) -> {agent_uid:, provider:}
|
|
11
|
+
# (single use)
|
|
12
|
+
# put_result(link_token, ...) hands the
|
|
13
|
+
# verified subject
|
|
14
|
+
# back to the app
|
|
15
|
+
# complete : peek_result(link_token) -> {...} (non-destructive,
|
|
16
|
+
# so the merge
|
|
17
|
+
# confirm can re-read)
|
|
18
|
+
# drop_result(link_token) on final success
|
|
19
|
+
#
|
|
20
|
+
# The Redis dependency is injected: pass either a raw client (responds to
|
|
21
|
+
# #get / #set / #del) or a connection pool (responds to #with yielding a
|
|
22
|
+
# client). Consumers register the instance via Persona.config.link_store.
|
|
23
|
+
class LinkStore
|
|
24
|
+
PENDING_PREFIX = 'persona:oidc:pending:'.freeze
|
|
25
|
+
RESULT_PREFIX = 'persona:oidc:result:'.freeze
|
|
26
|
+
PENDING_TTL = 600 # 10 min to complete the IdP round-trip
|
|
27
|
+
RESULT_TTL = 300 # 5 min to finish the client-side confirm
|
|
28
|
+
|
|
29
|
+
def initialize(redis:)
|
|
30
|
+
@redis = redis
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def generate_token
|
|
34
|
+
SecureRandom.urlsafe_base64(32)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# provider records which registered IdP this round-trip was begun for,
|
|
38
|
+
# so the callback verifies against the same provider (multi-provider
|
|
39
|
+
# deployments; docs/spec §7). stash carries the provider's per-flow
|
|
40
|
+
# secrets (PKCE code_verifier, nonce) from authorize to verify.
|
|
41
|
+
def put_pending(state, agent_uid:, provider:, stash: nil)
|
|
42
|
+
write(PENDING_PREFIX + state, { agent_uid: agent_uid, provider: provider, stash: stash }, PENDING_TTL)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Consumes the pending entry (state is single-use, CSRF-style).
|
|
46
|
+
def take_pending(state)
|
|
47
|
+
take(PENDING_PREFIX + state)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def put_result(link_token, provider:, subject:, agent_uid:)
|
|
51
|
+
write(
|
|
52
|
+
RESULT_PREFIX + link_token,
|
|
53
|
+
{ provider: provider, subject: subject, agent_uid: agent_uid },
|
|
54
|
+
RESULT_TTL,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Non-destructive: the merge-preview first call must leave the token
|
|
59
|
+
# valid for the confirm call.
|
|
60
|
+
def peek_result(link_token)
|
|
61
|
+
read(RESULT_PREFIX + link_token)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def drop_result(link_token)
|
|
65
|
+
with_redis { |conn| conn.del(RESULT_PREFIX + link_token) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def with_redis(&block)
|
|
71
|
+
if @redis.respond_to?(:with)
|
|
72
|
+
@redis.with(&block)
|
|
73
|
+
else
|
|
74
|
+
yield @redis
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def write(key, hash, ttl)
|
|
79
|
+
with_redis { |conn| conn.set(key, hash.to_json, ex: ttl) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read(key)
|
|
83
|
+
raw = with_redis { |conn| conn.get(key) }
|
|
84
|
+
return nil if raw.nil?
|
|
85
|
+
|
|
86
|
+
JSON.parse(raw, symbolize_names: true)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def take(key)
|
|
90
|
+
with_redis do |conn|
|
|
91
|
+
raw = conn.get(key)
|
|
92
|
+
conn.del(key)
|
|
93
|
+
return nil if raw.nil?
|
|
94
|
+
|
|
95
|
+
JSON.parse(raw, symbolize_names: true)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'base64'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'jwt'
|
|
8
|
+
|
|
9
|
+
module Persona
|
|
10
|
+
module Oidc
|
|
11
|
+
# Default HTTP client (Net::HTTP) used by Verifier for discovery, JWKS, and
|
|
12
|
+
# the token exchange. Injectable — tests pass a fake responding to the same
|
|
13
|
+
# #get / #post_form contract. Returns the response body on 2xx, nil otherwise.
|
|
14
|
+
class NetHttpClient
|
|
15
|
+
def get(url)
|
|
16
|
+
uri = URI(url)
|
|
17
|
+
request(uri, Net::HTTP::Get.new(uri))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def post_form(url, params)
|
|
21
|
+
uri = URI(url)
|
|
22
|
+
req = Net::HTTP::Post.new(uri)
|
|
23
|
+
req['Accept'] = 'application/json'
|
|
24
|
+
req.set_form_data(params)
|
|
25
|
+
request(uri, req)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def request(uri, req)
|
|
31
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |http| http.request(req) }
|
|
32
|
+
res.is_a?(Net::HTTPSuccess) ? res.body : nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Production OIDC verifier provider (docs/spec §7, P-16): the
|
|
37
|
+
# authorization-code + PKCE flow, OIDC discovery, code exchange, and
|
|
38
|
+
# id_token validation against the IdP's published JWKS. Mirrors the
|
|
39
|
+
# TypeScript createOidcVerifierProvider.
|
|
40
|
+
#
|
|
41
|
+
# Signature verification uses the jwt gem's OpenSSL backend and covers
|
|
42
|
+
# RS256 and ES256. EdDSA (Ed25519) is intentionally not enabled here — it
|
|
43
|
+
# needs rbnacl in Ruby — so an IdP signing with EdDSA must expose an
|
|
44
|
+
# RS256/ES256 key or wait for that follow-up.
|
|
45
|
+
#
|
|
46
|
+
# The clock (#now) is injectable so expiry is checked here rather than by
|
|
47
|
+
# the jwt gem, matching the TS verifier and keeping tests deterministic.
|
|
48
|
+
class Verifier
|
|
49
|
+
ALLOWED_ALGS = %w[RS256 ES256].freeze
|
|
50
|
+
|
|
51
|
+
attr_reader :name
|
|
52
|
+
|
|
53
|
+
def initialize(
|
|
54
|
+
name:, issuer:, client_id:, redirect_uri:,
|
|
55
|
+
client_secret: nil, scope: 'openid email',
|
|
56
|
+
authorization_endpoint: nil, token_endpoint: nil, jwks_uri: nil,
|
|
57
|
+
authorize_params: {}, require_hosted_domain: nil, allowed_email_domain: nil,
|
|
58
|
+
http: NetHttpClient.new, now: -> { Time.now.to_i }, clock_tolerance: 60
|
|
59
|
+
)
|
|
60
|
+
@name = name
|
|
61
|
+
@issuer = issuer.to_s.sub(%r{/\z}, '')
|
|
62
|
+
@client_id = client_id
|
|
63
|
+
@redirect_uri = redirect_uri
|
|
64
|
+
@client_secret = client_secret
|
|
65
|
+
@scope = scope
|
|
66
|
+
@explicit_endpoints = { authorization_endpoint: authorization_endpoint,
|
|
67
|
+
token_endpoint: token_endpoint, jwks_uri: jwks_uri }
|
|
68
|
+
@authorize_params = authorize_params
|
|
69
|
+
@require_hosted_domain = require_hosted_domain
|
|
70
|
+
@allowed_email_domain = allowed_email_domain
|
|
71
|
+
@http = http
|
|
72
|
+
@now = now
|
|
73
|
+
@clock_tolerance = clock_tolerance
|
|
74
|
+
@discovery = nil
|
|
75
|
+
@jwks = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Preset for uniba/auth (github.com/uniba/auth): the uniba.jp shared OIDC
|
|
79
|
+
# AS. Fills in the name, scope, and hd=uniba.jp upstream constraint; the
|
|
80
|
+
# issuer and client credentials stay per-deployment. auth is not yet
|
|
81
|
+
# published, so the token-claim domain check defaults to the email domain
|
|
82
|
+
# (provisional). Pass allowed_email_domain: nil to disable it.
|
|
83
|
+
def self.uniba_auth(issuer:, client_id:, redirect_uri:, client_secret: nil,
|
|
84
|
+
allowed_email_domain: 'uniba.jp',
|
|
85
|
+
http: NetHttpClient.new, now: -> { Time.now.to_i })
|
|
86
|
+
new(
|
|
87
|
+
name: 'uniba-auth', issuer: issuer, client_id: client_id, redirect_uri: redirect_uri,
|
|
88
|
+
client_secret: client_secret, scope: 'openid email',
|
|
89
|
+
authorize_params: { hd: 'uniba.jp' }, allowed_email_domain: allowed_email_domain,
|
|
90
|
+
http: http, now: now,
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def authorize(state)
|
|
95
|
+
code_verifier = SecureRandom.urlsafe_base64(32)
|
|
96
|
+
nonce = SecureRandom.urlsafe_base64(16)
|
|
97
|
+
query = {
|
|
98
|
+
response_type: 'code', client_id: @client_id, redirect_uri: @redirect_uri,
|
|
99
|
+
scope: @scope, state: state, nonce: nonce,
|
|
100
|
+
code_challenge: base64url(Digest::SHA256.digest(code_verifier)),
|
|
101
|
+
code_challenge_method: 'S256',
|
|
102
|
+
}.merge(@authorize_params)
|
|
103
|
+
url = "#{discovery.fetch(:authorization_endpoint)}?#{URI.encode_www_form(query)}"
|
|
104
|
+
AuthorizeStart.new(url: url, stash: { code_verifier: code_verifier, nonce: nonce })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def verify(params, stash = nil)
|
|
108
|
+
code = params[:code] || params['code']
|
|
109
|
+
return nil if code.nil? || code.to_s.empty?
|
|
110
|
+
|
|
111
|
+
tokens = exchange_code(code, stash)
|
|
112
|
+
id_token = tokens && (tokens['id_token'] || tokens[:id_token])
|
|
113
|
+
return nil if id_token.nil?
|
|
114
|
+
|
|
115
|
+
validate_id_token(id_token, stash)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def discovery
|
|
121
|
+
return @discovery if @discovery
|
|
122
|
+
|
|
123
|
+
if @explicit_endpoints.values.all?
|
|
124
|
+
return @discovery = @explicit_endpoints
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
body = @http.get("#{@issuer}/.well-known/openid-configuration")
|
|
128
|
+
raise "OIDC discovery failed for #{@issuer}" if body.nil?
|
|
129
|
+
|
|
130
|
+
doc = JSON.parse(body)
|
|
131
|
+
if doc['issuer'] && doc['issuer'].sub(%r{/\z}, '') != @issuer
|
|
132
|
+
raise "OIDC issuer mismatch: expected #{@issuer}, got #{doc['issuer']}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
@discovery = {
|
|
136
|
+
authorization_endpoint: @explicit_endpoints[:authorization_endpoint] || doc['authorization_endpoint'],
|
|
137
|
+
token_endpoint: @explicit_endpoints[:token_endpoint] || doc['token_endpoint'],
|
|
138
|
+
jwks_uri: @explicit_endpoints[:jwks_uri] || doc['jwks_uri'],
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def exchange_code(code, stash)
|
|
143
|
+
form = { grant_type: 'authorization_code', code: code,
|
|
144
|
+
redirect_uri: @redirect_uri, client_id: @client_id }
|
|
145
|
+
code_verifier = stash && stash_value(stash, :code_verifier)
|
|
146
|
+
form[:code_verifier] = code_verifier if code_verifier
|
|
147
|
+
form[:client_secret] = @client_secret if @client_secret
|
|
148
|
+
|
|
149
|
+
body = @http.post_form(discovery.fetch(:token_endpoint), form)
|
|
150
|
+
return nil if body.nil?
|
|
151
|
+
|
|
152
|
+
JSON.parse(body)
|
|
153
|
+
rescue JSON::ParserError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_id_token(id_token, stash)
|
|
158
|
+
loader = lambda do |options|
|
|
159
|
+
@jwks = nil if options[:invalidate]
|
|
160
|
+
{ keys: jwks_keys }
|
|
161
|
+
end
|
|
162
|
+
# Verify the signature (and restrict algorithms) with the jwt gem; check
|
|
163
|
+
# every claim here, against the injected clock.
|
|
164
|
+
payload, = JWT.decode(id_token, nil, true, algorithms: ALLOWED_ALGS, jwks: loader,
|
|
165
|
+
verify_expiration: false)
|
|
166
|
+
return nil unless valid_claims?(payload, stash)
|
|
167
|
+
|
|
168
|
+
Identity.new(provider: @name, subject: payload['sub'])
|
|
169
|
+
rescue JWT::DecodeError, JWT::JWKError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def valid_claims?(payload, stash)
|
|
174
|
+
now = @now.call
|
|
175
|
+
return false unless payload['iss'].to_s.sub(%r{/\z}, '') == @issuer
|
|
176
|
+
|
|
177
|
+
aud = payload['aud']
|
|
178
|
+
return false unless aud == @client_id || (aud.is_a?(Array) && aud.include?(@client_id))
|
|
179
|
+
|
|
180
|
+
exp = payload['exp']
|
|
181
|
+
return false unless exp.is_a?(Numeric) && exp + @clock_tolerance > now
|
|
182
|
+
|
|
183
|
+
nbf = payload['nbf']
|
|
184
|
+
return false if nbf.is_a?(Numeric) && nbf - @clock_tolerance > now
|
|
185
|
+
|
|
186
|
+
if stash
|
|
187
|
+
nonce = stash_value(stash, :nonce)
|
|
188
|
+
return false unless nonce.nil? || payload['nonce'] == nonce
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return false if @require_hosted_domain && payload['hd'] != @require_hosted_domain
|
|
192
|
+
|
|
193
|
+
if @allowed_email_domain
|
|
194
|
+
email = payload['email'].to_s.downcase
|
|
195
|
+
return false unless payload['email_verified'] == true &&
|
|
196
|
+
email.end_with?("@#{@allowed_email_domain.downcase}")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
sub = payload['sub']
|
|
200
|
+
!(sub.nil? || sub.to_s.empty?)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def jwks_keys
|
|
204
|
+
return @jwks if @jwks
|
|
205
|
+
|
|
206
|
+
body = @http.get(discovery.fetch(:jwks_uri))
|
|
207
|
+
raise 'JWKS fetch failed' if body.nil?
|
|
208
|
+
|
|
209
|
+
@jwks = JSON.parse(body)['keys']
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Stash may come back from JSON storage with either symbol or string keys.
|
|
213
|
+
def stash_value(stash, key)
|
|
214
|
+
stash[key] || stash[key.to_s]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def base64url(bytes)
|
|
218
|
+
Base64.urlsafe_encode64(bytes, padding: false)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
data/lib/persona/oidc.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
|
|
3
|
+
module Persona
|
|
4
|
+
# OIDC account linking — the verification half of binding a persona to an
|
|
5
|
+
# external identity provider. persona-protocol owns the flow (docs/spec
|
|
6
|
+
# §7); the application registers one provider object per IdP via
|
|
7
|
+
# Persona.config.register_oidc_provider, and gains new providers by
|
|
8
|
+
# updating this gem and registering them — not by reimplementing the flow.
|
|
9
|
+
#
|
|
10
|
+
# A provider is any object exposing:
|
|
11
|
+
#
|
|
12
|
+
# name -> String stable identifier stored on account
|
|
13
|
+
# bindings ('google', 'uniba-auth', ...)
|
|
14
|
+
# authorize(state) -> AuthorizeStart the authorize URL plus any per-flow
|
|
15
|
+
# secrets (PKCE verifier, nonce) to
|
|
16
|
+
# stash with the state
|
|
17
|
+
# verify(params, stash = nil) -> Identity | nil callback verification,
|
|
18
|
+
# using the stashed secrets
|
|
19
|
+
#
|
|
20
|
+
# Persona::Oidc::Verifier is the production implementation (code exchange +
|
|
21
|
+
# JWKS validation); StubProvider is the local stand-in.
|
|
22
|
+
module Oidc
|
|
23
|
+
# The verified outcome of an OIDC round-trip: which provider vouched for
|
|
24
|
+
# the browser, and the stable subject the token was issued for. This
|
|
25
|
+
# pair becomes an account binding.
|
|
26
|
+
Identity = Struct.new(:provider, :subject, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# What #authorize returns: where to send the browser, and the per-flow
|
|
29
|
+
# secrets to stash with the state and hand back to #verify on the
|
|
30
|
+
# callback. The stub needs no stash; a real code+PKCE flow does.
|
|
31
|
+
AuthorizeStart = Struct.new(:url, :stash, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
# Local / development stand-in for a real provider. Its authorize URL
|
|
34
|
+
# points at an app-local page, and #verify trusts a subject handed
|
|
35
|
+
# straight through the callback params — NO cryptographic verification.
|
|
36
|
+
# It exists so the whole bind / adopt / merge flow can be exercised
|
|
37
|
+
# before any real IdP is wired up. MUST NOT be deployed to production
|
|
38
|
+
# (protocol.md P-16).
|
|
39
|
+
class StubProvider
|
|
40
|
+
attr_reader :name
|
|
41
|
+
|
|
42
|
+
def initialize(name: 'stub', authorize_path: '/auth/oidc/start')
|
|
43
|
+
@name = name
|
|
44
|
+
@authorize_path = authorize_path
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def authorize(state)
|
|
48
|
+
AuthorizeStart.new(url: "#{@authorize_path}?state=#{CGI.escape(state)}", stash: nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def verify(params, _stash = nil)
|
|
52
|
+
subject = params[:sub] || params['sub']
|
|
53
|
+
return nil if subject.nil? || subject.to_s.empty?
|
|
54
|
+
|
|
55
|
+
Identity.new(provider: name, subject: subject)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/persona.rb
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
|
|
3
|
+
require_relative 'persona/version'
|
|
4
|
+
require_relative 'persona/claim_code'
|
|
5
|
+
require_relative 'persona/oidc'
|
|
6
|
+
require_relative 'persona/oidc/verifier'
|
|
7
|
+
require_relative 'persona/oidc/link_store'
|
|
8
|
+
require_relative 'persona/account_link'
|
|
9
|
+
|
|
10
|
+
# Persona — the app-agnostic core of the "anonymous identity you carry per
|
|
11
|
+
# browser" mechanism. The protocol itself is specified in docs/spec;
|
|
12
|
+
# this gem is one adapter around it.
|
|
13
|
+
#
|
|
14
|
+
# The identity records themselves (agent_uid -> user resolution, guest
|
|
15
|
+
# creation, bindings, recovery codes, merge) live on the consumer's models.
|
|
16
|
+
# This gem owns the *seams* where app-specific concerns plug in:
|
|
17
|
+
#
|
|
18
|
+
# - on_join domain side effects to run when a browser joins
|
|
19
|
+
# - guest_nickname_generator what a fresh guest is named when none is given
|
|
20
|
+
# - guest_email_factory the placeholder identifier stored on a new guest
|
|
21
|
+
# - oidc_providers registered identity providers (see oidc.rb)
|
|
22
|
+
# - account_link_store the AccountLink storage port (see account_link.rb)
|
|
23
|
+
# - link_store the OIDC round-trip store (see oidc/link_store.rb)
|
|
24
|
+
# - AGENT_ID_HEADER / AGENT_ID_PARAM / NOT_JOINED_CODE the wire protocol
|
|
25
|
+
#
|
|
26
|
+
# Consumer wiring examples live in examples/ at the repository root.
|
|
27
|
+
module Persona
|
|
28
|
+
class ConfigurationError < StandardError; end
|
|
29
|
+
|
|
30
|
+
# HTTP header and WebSocket query param (for transports that can't set
|
|
31
|
+
# custom headers) that carry the browser-generated agent_uid. The value
|
|
32
|
+
# itself is never echoed back in responses — docs/spec P-3 / H-3.
|
|
33
|
+
AGENT_ID_HEADER = 'X-Agent-Id'.freeze
|
|
34
|
+
AGENT_ID_PARAM = 'agent_id'.freeze
|
|
35
|
+
|
|
36
|
+
# Error code returned when a write is attempted by a visitor who hasn't
|
|
37
|
+
# opted in yet; the client reacts by starting the join handshake and
|
|
38
|
+
# retrying. How the code travels (GraphQL extension, HTTP status + body,
|
|
39
|
+
# HTML fragment) is transport-specific — the name is the protocol.
|
|
40
|
+
NOT_JOINED_CODE = 'NOT_JOINED'.freeze
|
|
41
|
+
|
|
42
|
+
# Default guest-nickname vocabulary: adjective-noun-hex, readable and
|
|
43
|
+
# collision-resistant enough for a display handle. Consumers can replace
|
|
44
|
+
# the whole generator via Persona.config.guest_nickname_generator.
|
|
45
|
+
NICKNAME_ADJECTIVES = %w[
|
|
46
|
+
azure crimson emerald golden silver violet amber coral indigo jade
|
|
47
|
+
onyx pearl ruby sapphire topaz cobalt scarlet teal magenta saffron
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
NICKNAME_NOUNS = %w[
|
|
51
|
+
otter falcon mantis lynx ibis pangolin heron badger marmot newt
|
|
52
|
+
raven sparrow tapir gecko axolotl puffin lemur kestrel quokka tern
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
# Env values treated as "off" for feature flags, mirroring the usual
|
|
56
|
+
# Rails-style boolean casting so consumers coming from ActiveModel see no
|
|
57
|
+
# behavior change. Anything else non-empty counts as "on".
|
|
58
|
+
FALSEY_ENV_VALUES = %w[0 f F false FALSE off OFF].freeze
|
|
59
|
+
|
|
60
|
+
# Injection points for the app-specific parts of the persona flow. Every
|
|
61
|
+
# default is a self-contained generic — the gem has no domain dependencies
|
|
62
|
+
# until the app overrides a seam.
|
|
63
|
+
class Config
|
|
64
|
+
# Called with the freshly-resolved guest user on every join (both the
|
|
65
|
+
# first join and returning visits). Must be idempotent. Defaults to a
|
|
66
|
+
# no-op; the app injects its membership side effects here.
|
|
67
|
+
attr_accessor :on_join
|
|
68
|
+
|
|
69
|
+
# Builds the display nickname for a new guest when the caller supplies
|
|
70
|
+
# none.
|
|
71
|
+
attr_accessor :guest_nickname_generator
|
|
72
|
+
|
|
73
|
+
# Builds the placeholder email/identifier persisted on a new guest user.
|
|
74
|
+
# It MUST NOT be derived from the agent_uid or the session credential: this
|
|
75
|
+
# value is exposable (a settings page, a profile view echoes it), so
|
|
76
|
+
# deriving it would leak the credential back (P-3). The default draws from
|
|
77
|
+
# independent randomness for exactly this reason.
|
|
78
|
+
attr_accessor :guest_email_factory
|
|
79
|
+
|
|
80
|
+
# Registered OIDC providers by name (see Persona::Oidc for the provider
|
|
81
|
+
# contract). Empty by default: register providers explicitly, including
|
|
82
|
+
# the development stub — an unregistered provider can never verify.
|
|
83
|
+
attr_reader :oidc_providers
|
|
84
|
+
|
|
85
|
+
def register_oidc_provider(provider)
|
|
86
|
+
@oidc_providers[provider.name] = provider
|
|
87
|
+
provider
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Storage port for AccountLink — an object implementing the contract
|
|
91
|
+
# documented in Persona::AccountLink. No default: consumers that enable
|
|
92
|
+
# account linking must provide one backed by their own persistence.
|
|
93
|
+
attr_accessor :account_link_store
|
|
94
|
+
|
|
95
|
+
# Storage for the OIDC redirect round-trip, e.g.
|
|
96
|
+
# Persona::Oidc::LinkStore.new(redis: <client or pool>). No default:
|
|
97
|
+
# consumers that enable account linking must provide one.
|
|
98
|
+
attr_accessor :link_store
|
|
99
|
+
|
|
100
|
+
def initialize
|
|
101
|
+
@on_join = ->(_user) {}
|
|
102
|
+
@guest_nickname_generator = -> { Persona.generate_nickname }
|
|
103
|
+
@guest_email_factory = -> { "agent-#{SecureRandom.hex(8)}@guest.local" }
|
|
104
|
+
@oidc_providers = {}
|
|
105
|
+
@account_link_store = nil
|
|
106
|
+
@link_store = nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
class << self
|
|
111
|
+
def config
|
|
112
|
+
@config ||= Config.new
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def configure
|
|
116
|
+
yield config
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Discards all wiring and returns to defaults. Intended for test suites.
|
|
120
|
+
def reset_config!
|
|
121
|
+
@config = Config.new
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def generate_nickname
|
|
125
|
+
"#{NICKNAME_ADJECTIVES.sample}-#{NICKNAME_NOUNS.sample}-#{SecureRandom.hex(2)}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Resolves a registered OIDC provider by name; unknown names raise so a
|
|
129
|
+
# begin/callback for a provider the app never registered fails loudly.
|
|
130
|
+
def oidc_provider(name)
|
|
131
|
+
config.oidc_providers.fetch(name) do
|
|
132
|
+
raise ConfigurationError, "unknown OIDC provider #{name.inspect} — " \
|
|
133
|
+
'register it via Persona.config.register_oidc_provider'
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Account linking (OIDC binding to an external IdP) is opt-in and off by
|
|
138
|
+
# default — until it is enabled, no /auth/oidc routes or link mutations
|
|
139
|
+
# do anything. Toggled by the PERSONA_ACCOUNT_LINKING env var.
|
|
140
|
+
def account_linking_enabled?
|
|
141
|
+
value = ENV.fetch('PERSONA_ACCOUNT_LINKING', nil)
|
|
142
|
+
return false if value.nil? || value.empty?
|
|
143
|
+
|
|
144
|
+
!FALSEY_ENV_VALUES.include?(value)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: persona-protocol
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- UNIBA COMMONS Authors
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-07 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: jwt
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.7'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.7'
|
|
27
|
+
description: 'Seams, OIDC verifier contract, and account-link decision logic for the
|
|
28
|
+
persona-protocol: start using an app with no login, carry a per-browser persona,
|
|
29
|
+
and optionally graft it onto a verified account later.'
|
|
30
|
+
email:
|
|
31
|
+
- haruma@uniba.jp
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- AUTHORS
|
|
37
|
+
- LICENSE
|
|
38
|
+
- lib/persona.rb
|
|
39
|
+
- lib/persona/account_link.rb
|
|
40
|
+
- lib/persona/claim_code.rb
|
|
41
|
+
- lib/persona/oidc.rb
|
|
42
|
+
- lib/persona/oidc/link_store.rb
|
|
43
|
+
- lib/persona/oidc/verifier.rb
|
|
44
|
+
- lib/persona/version.rb
|
|
45
|
+
homepage: https://github.com/uniba-commons/persona-protocol
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
rubygems_mfa_required: 'true'
|
|
50
|
+
post_install_message:
|
|
51
|
+
rdoc_options: []
|
|
52
|
+
require_paths:
|
|
53
|
+
- lib
|
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '3.1'
|
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0'
|
|
64
|
+
requirements: []
|
|
65
|
+
rubygems_version: 3.5.9
|
|
66
|
+
signing_key:
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: 'Portable anonymous identity: browser-carried personas with optional OIDC
|
|
69
|
+
account linking'
|
|
70
|
+
test_files: []
|