Class: LogiAuth::Server

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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.

Raises:

  • (ArgumentError)


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_idObject (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_scopesObject (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

#issuerObject (readonly)

Returns the value of attribute issuer.



25
26
27
# File 'lib/logi_auth/server.rb', line 25

def issuer
  @issuer
end

#redirect_uriObject (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_issuerObject (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 authorization_url(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