Module: Keycardai::OAuth

Defined in:
lib/keycardai/oauth.rb,
lib/keycardai/oauth/http.rb,
lib/keycardai/oauth/pkce.rb,
lib/keycardai/oauth/errors.rb,
lib/keycardai/oauth/version.rb,
lib/keycardai/oauth/discovery.rb,
lib/keycardai/oauth/jwt_signer.rb,
lib/keycardai/oauth/private_key.rb,
lib/keycardai/oauth/token_types.rb,
lib/keycardai/oauth/authenticate.rb,
lib/keycardai/oauth/jwks_keyring.rb,
lib/keycardai/oauth/jwt_verifier.rb,
lib/keycardai/oauth/registration.rb,
lib/keycardai/oauth/web_identity.rb,
lib/keycardai/oauth/client_secret.rb,
lib/keycardai/oauth/token_sources.rb,
lib/keycardai/oauth/access_context.rb,
lib/keycardai/oauth/token_requests.rb,
lib/keycardai/oauth/token_verifier.rb,
lib/keycardai/oauth/exchange_tokens.rb,
lib/keycardai/oauth/substitute_user.rb,
lib/keycardai/oauth/workload_identity.rb,
lib/keycardai/oauth/authorization_code.rb,
lib/keycardai/oauth/token_exchange_client.rb,
lib/keycardai/oauth/client_credentials_client.rb

Overview

Authorization-code grant building blocks (RFC 6749 §4.1 + RFC 7636): the authorize-URL builder and the back-channel code exchange.

Defined Under Namespace

Modules: Discovery, ExchangeTokens, GrantType, HTTP, Loopback, PKCE, Registration, TokenRequests, TokenType Classes: AccessContext, AccessToken, AuthorizationServerMetadata, ClientCredentialsClient, ClientRegistrationResponse, ClientSecret, ConfigurationError, FilePrivateKeyStorage, FileTokenSource, FlyTokenSource, GCPMetadataTokenSource, HTTPError, InteractionTimeoutError, InvalidTokenError, JWKSDiscoveryError, JWKSError, JWKSFetchError, JWKSKeyNotFoundError, JWKSKeyring, JWKSUriValidationError, JWTSigner, JWTVerifier, NetworkError, OAuthError, PrivateKeyManager, ProtocolError, ResourceAccessError, TokenExchangeClient, TokenResponse, TokenVerifier, WebIdentity, WorkloadIdentity, WorkloadIdentityConfigurationError, WorkloadIdentityRuntimeError

Constant Summary collapse

VERSION =
"0.2.0"
DEFAULT_CALLBACK_PORT =
8765
DEFAULT_CALLBACK_TIMEOUT =
300
REGISTRATION_METADATA_FIELDS =
%w[
  client_name redirect_uris grant_types response_types scope
  token_endpoint_auth_method jwks_uri jwks client_uri logo_uri tos_uri
  policy_uri software_id software_version
].freeze

Class Method Summary collapse

Class Method Details

.authenticate(issuer:, client_id:, scope: nil, resource: nil, port: DEFAULT_CALLBACK_PORT, callback_timeout: DEFAULT_CALLBACK_TIMEOUT, client_secret: nil, verifier_length: PKCE::DEFAULT_VERIFIER_LENGTH, http_client: HTTP::NetHTTPClient.new, browser_opener: nil, timeout: nil) ⇒ TokenResponse

Run the full authorization-code + PKCE login flow: generate the PKCE pair and a CSRF state, build the authorize URL, open the user's browser, receive the redirect on a local loopback server, validate the state, and exchange the code for a token.

Parameters:

  • issuer (String)

    the zone's issuer URL

  • client_id (String)
  • scope (String, nil) (defaults to: nil)

    space-separated scopes

  • resource (String, nil) (defaults to: nil)

    RFC 8707 resource indicator

  • port (Integer) (defaults to: DEFAULT_CALLBACK_PORT)

    loopback port; 0 binds an ephemeral port

  • callback_timeout (Numeric) (defaults to: DEFAULT_CALLBACK_TIMEOUT)

    seconds to wait for the redirect

  • client_secret (String, nil) (defaults to: nil)

    confidential clients only

  • verifier_length (Integer) (defaults to: PKCE::DEFAULT_VERIFIER_LENGTH)

    PKCE verifier length, 43 to 128

  • http_client (#get, #post_form) (defaults to: HTTP::NetHTTPClient.new)

    pluggable transport

  • browser_opener (#call, nil) (defaults to: nil)

    receives the authorize URL; defaults to the platform opener (open / xdg-open / cmd start), shell-free

  • timeout (Numeric, nil) (defaults to: nil)

    HTTP timeout for discovery and exchange

Returns:

Raises:



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/keycardai/oauth/authenticate.rb', line 36

def self.authenticate(issuer:, client_id:, scope: nil, resource: nil, port: DEFAULT_CALLBACK_PORT,
                      callback_timeout: DEFAULT_CALLBACK_TIMEOUT, client_secret: nil,
                      verifier_length: PKCE::DEFAULT_VERIFIER_LENGTH,
                      http_client: HTTP::NetHTTPClient.new, browser_opener: nil, timeout: nil)
  authorization_endpoint = authorization_endpoint_for(issuer, http_client, timeout)
  pair = PKCE.generate_pair(length: verifier_length)
  state = SecureRandom.urlsafe_base64(24)

  Loopback::CallbackServer.open(port: port) do |server|
    open_authorize_page(server, authorization_endpoint, pair, state,
                        client_id: client_id, scope: scope, resource: resource,
                        browser_opener: browser_opener)
    code = server.wait_for_code(state: state, timeout: callback_timeout)
    exchange_authorization_code(
      issuer,
      code: code, code_verifier: pair.code_verifier, redirect_uri: server.redirect_uri,
      client_id: client_id, client_secret: client_secret, resource: resource,
      http_client: http_client, timeout: timeout
    )
  end
end

.authenticate_from_challenge(www_authenticate, http_client: HTTP::NetHTTPClient.new, **options) ⇒ TokenResponse

Challenge-driven entry to the login flow: resolve the issuer from a WWW-Authenticate challenge, then run authenticate against it.

Parameters:

  • www_authenticate (String)

    the WWW-Authenticate header value

  • options (Hash)

    forwarded to authenticate

Returns:



102
103
104
105
106
# File 'lib/keycardai/oauth/authenticate.rb', line 102

def self.authenticate_from_challenge(www_authenticate, http_client: HTTP::NetHTTPClient.new, **options)
  issuer = resolve_issuer_from_challenge(www_authenticate, http_client: http_client,
                                                           timeout: options[:timeout])
  authenticate(issuer: issuer, http_client: http_client, **options)
end

.build_authorize_url(authorization_endpoint, client_id:, redirect_uri:, code_challenge:, code_challenge_method: "S256", scope: nil, state: nil, resource: nil) ⇒ String

Build the authorization request URL.

Parameters:

  • authorization_endpoint (String)

    from discovery

  • client_id (String)
  • redirect_uri (String)
  • code_challenge (String)

    the PKCE challenge

  • code_challenge_method (String) (defaults to: "S256")

    the method the challenge was derived with

  • scope (String, nil) (defaults to: nil)

    space-separated scopes

  • state (String, nil) (defaults to: nil)

    CSRF state value

  • resource (String, nil) (defaults to: nil)

    RFC 8707 resource indicator

Returns:

  • (String)

    the authorize URL



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/keycardai/oauth/authorization_code.rb', line 20

def self.build_authorize_url(authorization_endpoint, client_id:, redirect_uri:, code_challenge:,
                             code_challenge_method: "S256", scope: nil, state: nil, resource: nil)
  params = {
    "response_type" => "code",
    "client_id" => client_id,
    "redirect_uri" => redirect_uri,
    "code_challenge" => code_challenge,
    "code_challenge_method" => code_challenge_method,
    "scope" => scope,
    "state" => state,
    "resource" => resource
  }.compact

  uri = URI(authorization_endpoint)
  query = URI.encode_www_form(params)
  uri.query = uri.query.nil? || uri.query.empty? ? query : "#{uri.query}&#{query}"
  uri.to_s
end

.build_substitute_user_token(user_identifier) ⇒ String

Build the unsigned substitute-user JWT used as the subject token of an impersonation exchange. The authorization server derives the acting party from client authentication; this token only names the target user.

Shape: header "vnd.kc.su+jwt", "alg": "none", payload user_identifier, encoded header.payload. with a trailing dot and no signature.

Parameters:

  • user_identifier (String)

    the target user (becomes sub)

Returns:

  • (String)


18
19
20
21
22
23
24
25
26
27
# File 'lib/keycardai/oauth/substitute_user.rb', line 18

def self.build_substitute_user_token(user_identifier)
  if user_identifier.nil? || user_identifier.empty?
    raise ArgumentError, "user_identifier must be a non-empty string"
  end

  header = { "typ" => "vnd.kc.su+jwt", "alg" => "none" }
  payload = { "sub" => user_identifier }
  encode = ->(part) { [JSON.dump(part)].pack("m0").tr("+/", "-_").delete("=") }
  "#{encode.call(header)}.#{encode.call(payload)}."
end

.exchange_authorization_code(issuer, code:, code_verifier:, redirect_uri:, client_id: nil, client_secret: nil, resource: nil, http_client: HTTP::NetHTTPClient.new, timeout: nil) ⇒ TokenResponse

Exchange an authorization code for a token (RFC 6749 §4.1.3). A public client sends its client_id in the body; a confidential client authenticates with HTTP Basic and omits client_id from the body.

Parameters:

  • issuer (String)

    the zone's issuer URL; supplies the token endpoint

  • code (String)

    the authorization code from the redirect

  • code_verifier (String)

    the PKCE verifier matching the challenge

  • redirect_uri (String)

    must match the authorization request

  • client_id (String, nil) (defaults to: nil)

    public-client identifier

  • client_secret (String, nil) (defaults to: nil)

    confidential-client secret; requires client_id

  • resource (String, nil) (defaults to: nil)

    RFC 8707 resource indicator

  • http_client (#get, #post_form) (defaults to: HTTP::NetHTTPClient.new)

    pluggable transport

  • timeout (Numeric, nil) (defaults to: nil)

Returns:

Raises:



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/keycardai/oauth/authorization_code.rb', line 55

def self.exchange_authorization_code(issuer, code:, code_verifier:, redirect_uri:, client_id: nil,
                                     client_secret: nil, resource: nil,
                                     http_client: HTTP::NetHTTPClient.new, timeout: nil)
  raise ConfigurationError, "client_secret requires client_id" if client_secret && client_id.nil?

   = (issuer, http_client: http_client, timeout: timeout)
  endpoint = .token_endpoint ||
             raise(ProtocolError.new("metadata for #{issuer} has no token_endpoint", code: "invalid_metadata"))

  # A confidential client authenticates with Basic and omits client_id
  # from the body; a public client carries client_id in the body.
  params = {
    "grant_type" => GrantType::AUTHORIZATION_CODE,
    "code" => code,
    "code_verifier" => code_verifier,
    "redirect_uri" => redirect_uri,
    "client_id" => client_secret ? nil : client_id,
    "resource" => resource
  }.compact
  headers = { "Accept" => "application/json" }
  headers["Authorization"] = HTTP.basic_authorization(client_id, client_secret) if client_secret

  TokenRequests.parse_response(http_client.post_form(endpoint, params, headers: headers, timeout: timeout))
end

.exchange_tokens_for_resources(client:, resources:, subject_token: nil, access_context: AccessContext.new, user_identifier: nil, request_scopes: nil, issuer: nil) ⇒ AccessContext

Exchange a subject token for tokens targeting multiple resources, recording each success or failure on an AccessContext. Non-throwing by design: per-resource failures land on the context so a partial-success flow can proceed. When user_identifier is set, each exchange is an impersonation instead of a subject-token exchange.

Parameters:

  • client (TokenExchangeClient)

    carries the credential and zone

  • resources (Array<String>)

    the target resources

  • subject_token (String, nil) (defaults to: nil)

    the inbound token being delegated; required unless user_identifier is given

  • access_context (AccessContext) (defaults to: AccessContext.new)

    the container to populate

  • user_identifier (String, nil) (defaults to: nil)

    impersonation target

  • request_scopes (String, Hash{String => String}, nil) (defaults to: nil)

    scopes for every exchange, or a per-resource map

  • issuer (String, nil) (defaults to: nil)

    per-call zone selection

Returns:



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/keycardai/oauth/exchange_tokens.rb', line 22

def self.exchange_tokens_for_resources(client:, resources:, subject_token: nil,
                                       access_context: AccessContext.new, user_identifier: nil,
                                       request_scopes: nil, issuer: nil)
  if subject_token.nil? && user_identifier.nil?
    raise ArgumentError, "subject_token is required unless user_identifier is given"
  end

  resources.each do |resource|
    scope = request_scopes.is_a?(Hash) ? request_scopes[resource] : request_scopes
    token = ExchangeTokens.exchange_one(client: client, resource: resource, subject_token: subject_token,
                                        user_identifier: user_identifier, scope: scope, issuer: issuer)
    access_context.set_token(resource, token)
  rescue Keycardai::Error => e
    access_context.set_resource_error(resource, e)
  end
  access_context
end

.fetch_authorization_server_metadata(issuer, http_client: HTTP::NetHTTPClient.new, timeout: nil) ⇒ AuthorizationServerMetadata

Discover OAuth 2.0 authorization-server metadata from an issuer URL (RFC 8414). Performs a single fetch and does not cache; caching belongs to the callers that depend on the endpoints.

Parameters:

  • issuer (String)

    the authorization server's identifier URL

  • http_client (#get) (defaults to: HTTP::NetHTTPClient.new)

    pluggable transport

  • timeout (Numeric, nil) (defaults to: nil)

    fetch timeout in seconds

Returns:

Raises:



37
38
39
40
41
42
43
44
45
46
# File 'lib/keycardai/oauth/discovery.rb', line 37

def self.(issuer, http_client: HTTP::NetHTTPClient.new, timeout: nil)
  url = Discovery.(issuer)
  response = http_client.get(url, headers: { "Accept" => "application/json" }, timeout: timeout)
  unless response.success?
    raise HTTPError.new("discovery for #{issuer} returned HTTP #{response.status}",
                        status: response.status, body: response.body)
  end

  Discovery.(issuer, response.body)
end

.register_client(issuer, client_name: nil, redirect_uris: nil, grant_types: nil, response_types: nil, scope: nil, token_endpoint_auth_method: nil, jwks_uri: nil, jwks: nil, client_uri: nil, logo_uri: nil, tos_uri: nil, policy_uri: nil, software_id: nil, software_version: nil, additional_metadata: nil, initial_access_token: nil, http_client: HTTP::NetHTTPClient.new, timeout: nil) ⇒ ClientRegistrationResponse

Register a new OAuth client with the zone (RFC 7591). Sends only the fields the caller supplies; omitted metadata is defaulted by the authorization server. Vendor-extension fields go in additional_metadata, with named fields winning on conflict.

Parameters:

  • issuer (String)

    the zone's issuer URL; supplies the registration endpoint

  • client_name (String, nil) (defaults to: nil)
  • redirect_uris (Array<String>, nil) (defaults to: nil)

    required by RFC 7591 when grant_types includes authorization_code; enforced by the server

  • grant_types (Array<String>, nil) (defaults to: nil)
  • response_types (Array<String>, nil) (defaults to: nil)
  • scope (String, nil) (defaults to: nil)

    space-separated scopes

  • token_endpoint_auth_method (String, nil) (defaults to: nil)
  • jwks_uri (String, nil) (defaults to: nil)
  • jwks (Hash, nil) (defaults to: nil)
  • client_uri (String, nil) (defaults to: nil)
  • logo_uri (String, nil) (defaults to: nil)
  • tos_uri (String, nil) (defaults to: nil)
  • policy_uri (String, nil) (defaults to: nil)
  • software_id (String, nil) (defaults to: nil)
  • software_version (String, nil) (defaults to: nil)
  • additional_metadata (Hash, nil) (defaults to: nil)

    vendor or AS-specific fields

  • initial_access_token (String, nil) (defaults to: nil)

    RFC 7591 §3.1 registration authentication, sent as a Bearer credential

  • http_client (#get, #post_json) (defaults to: HTTP::NetHTTPClient.new)

    pluggable transport

  • timeout (Numeric, nil) (defaults to: nil)

Returns:

Raises:



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/keycardai/oauth/registration.rb', line 58

def self.register_client(issuer, client_name: nil, redirect_uris: nil, grant_types: nil,
                         response_types: nil, scope: nil, token_endpoint_auth_method: nil,
                         jwks_uri: nil, jwks: nil, client_uri: nil, logo_uri: nil, tos_uri: nil,
                         policy_uri: nil, software_id: nil, software_version: nil,
                         additional_metadata: nil, initial_access_token: nil,
                         http_client: HTTP::NetHTTPClient.new, timeout: nil)
  named = {
    "client_name" => client_name, "redirect_uris" => redirect_uris, "grant_types" => grant_types,
    "response_types" => response_types, "scope" => scope,
    "token_endpoint_auth_method" => token_endpoint_auth_method, "jwks_uri" => jwks_uri,
    "jwks" => jwks, "client_uri" => client_uri, "logo_uri" => logo_uri, "tos_uri" => tos_uri,
    "policy_uri" => policy_uri, "software_id" => software_id, "software_version" => software_version
  }.compact
  body = ( || {}).transform_keys(&:to_s).merge(named)

  Registration.post(issuer, body, initial_access_token, http_client, timeout)
end

.resolve_issuer_from_challenge(www_authenticate, http_client: HTTP::NetHTTPClient.new, timeout: nil) ⇒ String

Resolve the issuer from a resource's WWW-Authenticate challenge (RFC 9728): fetch the challenge's resource_metadata document and return its first authorization server.

Parameters:

  • www_authenticate (String)

    the WWW-Authenticate header value

  • http_client (#get) (defaults to: HTTP::NetHTTPClient.new)

    pluggable transport

  • timeout (Numeric, nil) (defaults to: nil)

Returns:

  • (String)

    the issuer URL

Raises:

  • (ProtocolError)

    no resource_metadata parameter, or a metadata document without authorization_servers



85
86
87
88
89
90
91
92
93
94
# File 'lib/keycardai/oauth/authenticate.rb', line 85

def self.resolve_issuer_from_challenge(www_authenticate, http_client: HTTP::NetHTTPClient.new, timeout: nil)
   = www_authenticate.to_s[/resource_metadata="([^"]+)"/, 1]
  unless 
    raise ProtocolError.new("challenge carries no resource_metadata parameter", code: "invalid_metadata")
  end

  document = Loopback.(, http_client, timeout)
  issuer = Array(document["authorization_servers"]).first
  issuer || raise(ProtocolError.new("resource metadata lists no authorization_servers", code: "invalid_metadata"))
end