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 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
@@ -0,0 +1,6 @@
1
+ # This is the list of people who have contributed to persona-protocol and are
2
+ # referred to collectively as "UNIBA COMMONS Authors" in the copyright notices.
3
+ #
4
+ # Format: Name <email>
5
+
6
+ Haruma Kikuchi <haruma@uniba.jp>
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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Persona
2
+ VERSION = '0.1.0'.freeze
3
+ 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: []