Class: LogiAuth::Server
- Inherits:
-
Object
- Object
- LogiAuth::Server
- Defined in:
- lib/logi_auth/server.rb
Overview
Server-side "Sign in with logi" for Ruby/Rails backends. Confidential-client OAuth 2.0 code exchange + id_token (RS256) verification.
Why this exists: a backend RP that skips the id_token aud check can be
tricked into accepting a token minted for a DIFFERENT client (cross-client
account takeover). #exchange_code_and_verify ALWAYS verifies
signature + iss + aud + exp + nonce before returning sub.
Defined Under Namespace
Classes: Session
Instance Attribute Summary collapse
-
#client_id ⇒ Object
readonly
Returns the value of attribute client_id.
-
#default_scopes ⇒ Object
readonly
Returns the value of attribute default_scopes.
-
#issuer ⇒ Object
readonly
Returns the value of attribute issuer.
-
#redirect_uri ⇒ Object
readonly
Returns the value of attribute redirect_uri.
-
#token_issuer ⇒ Object
readonly
Returns the value of attribute token_issuer.
Instance Method Summary collapse
-
#authorization_url(state:, nonce:, scopes: nil, code_challenge: nil, prompt: nil) ⇒ Object
Build the /oauth/authorize URL to redirect the browser to.
-
#exchange_code_and_verify(code:, nonce:, code_verifier: nil) ⇒ Object
Exchange the authorization code and verify the id_token.
-
#initialize(client_id:, redirect_uri:, client_secret: nil, issuer: "https://api.1pass.dev", token_issuer: "https://api.1pass.dev", scopes: %w[openid profile:basic email],, jwks_cache_ttl: 3600) ⇒ Server
constructor
A new instance of Server.
Constructor Details
#initialize(client_id:, redirect_uri:, client_secret: nil, issuer: "https://api.1pass.dev", token_issuer: "https://api.1pass.dev", scopes: %w[openid profile:basic email],, jwks_cache_ttl: 3600) ⇒ Server
Returns a new instance of Server.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/logi_auth/server.rb', line 27 def initialize(client_id:, redirect_uri:, client_secret: nil, issuer: "https://api.1pass.dev", token_issuer: "https://api.1pass.dev", scopes: %w[openid profile:basic email], jwks_cache_ttl: 3600) raise ArgumentError, "client_id is required" if client_id.nil? || client_id.empty? raise ArgumentError, "redirect_uri is required" if redirect_uri.nil? || redirect_uri.empty? @client_id = client_id @redirect_uri = redirect_uri @client_secret = client_secret @issuer = issuer.sub(%r{/+\z}, "") @token_issuer = token_issuer @default_scopes = scopes @jwks_cache_ttl = jwks_cache_ttl @jwks_cache = nil @jwks_fetched_at = 0 end |
Instance Attribute Details
#client_id ⇒ Object (readonly)
Returns the value of attribute client_id.
25 26 27 |
# File 'lib/logi_auth/server.rb', line 25 def client_id @client_id end |
#default_scopes ⇒ Object (readonly)
Returns the value of attribute default_scopes.
25 26 27 |
# File 'lib/logi_auth/server.rb', line 25 def default_scopes @default_scopes end |
#issuer ⇒ Object (readonly)
Returns the value of attribute issuer.
25 26 27 |
# File 'lib/logi_auth/server.rb', line 25 def issuer @issuer end |
#redirect_uri ⇒ Object (readonly)
Returns the value of attribute redirect_uri.
25 26 27 |
# File 'lib/logi_auth/server.rb', line 25 def redirect_uri @redirect_uri end |
#token_issuer ⇒ Object (readonly)
Returns the value of attribute token_issuer.
25 26 27 |
# File 'lib/logi_auth/server.rb', line 25 def token_issuer @token_issuer end |
Instance Method Details
#authorization_url(state:, nonce:, scopes: nil, code_challenge: nil, prompt: nil) ⇒ Object
Build the /oauth/authorize URL to redirect the browser to.
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/logi_auth/server.rb', line 45 def (state:, nonce:, scopes: nil, code_challenge: nil, prompt: nil) params = { "response_type" => "code", "client_id" => @client_id, "redirect_uri" => @redirect_uri, "scope" => (scopes || @default_scopes).join(" "), "state" => state, "nonce" => nonce } if code_challenge params["code_challenge"] = code_challenge params["code_challenge_method"] = "S256" end params["prompt"] = prompt if prompt "#{@issuer}/oauth/authorize?#{URI.encode_www_form(params)}" end |
#exchange_code_and_verify(code:, nonce:, code_verifier: nil) ⇒ Object
Exchange the authorization code and verify the id_token. Returns a verified Session (sub set only after signature + iss + aud + exp + nonce all pass). Raises ServerError on any failure.
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/logi_auth/server.rb', line 65 def exchange_code_and_verify(code:, nonce:, code_verifier: nil) # The server flow ALWAYS generated a nonce in authorization_url, so a # nil/empty nonce here (e.g. an expired session) is a bug โ never proceed # with the nonce check silently disabled. (codex P1.) if nonce.nil? || nonce.to_s.empty? raise ServerError.new("invalid_nonce", "nonce is required โ the sign-in session may have expired") end form = { "grant_type" => "authorization_code", "code" => code, "redirect_uri" => @redirect_uri, "client_id" => @client_id } form["client_secret"] = @client_secret if @client_secret form["code_verifier"] = code_verifier if code_verifier resp = http_post("#{@issuer}/oauth/token", form) unless success?(resp) raise ServerError.new("token_exchange_failed", "Token exchange failed (HTTP #{resp.code})", detail: truncate(resp.body)) end tokens = parse_token_body(resp.body) id_token = tokens["id_token"] unless id_token.is_a?(String) && !id_token.empty? raise ServerError.new("missing_id_token", "Token response had no id_token โ was `openid` in the scopes?") end # Thread the access_token so OIDC ยง3.1.3.6 at_hash binding is enforced # before the Session is returned (present-only: no-op when the id_token # carries no at_hash). parse_token_body guarantees a String access_token. verified = verify_with_rotation_retry(id_token, nonce, tokens["access_token"]) email = verified.claims["email"] expires_in = tokens["expires_in"] Session.new( sub: verified.sub, email: email.is_a?(String) ? email : nil, id_token: id_token, access_token: tokens["access_token"], refresh_token: tokens["refresh_token"], expires_at: expires_in.is_a?(Numeric) ? Time.now.to_i + expires_in.to_i : nil, scope: tokens["scope"], claims: verified.claims ) end |