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
-
.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.
-
.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.
-
.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.
-
.build_substitute_user_token(user_identifier) ⇒ String
Build the unsigned substitute-user JWT used as the subject token of an impersonation exchange.
-
.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).
-
.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.
-
.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).
-
.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).
-
.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.
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.
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) = (issuer, http_client, timeout) pair = PKCE.generate_pair(length: verifier_length) state = SecureRandom.urlsafe_base64(24) Loopback::CallbackServer.open(port: port) do |server| (server, , pair, state, client_id: client_id, scope: scope, resource: resource, browser_opener: browser_opener) code = server.wait_for_code(state: state, timeout: callback_timeout) ( 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.
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, **) issuer = resolve_issuer_from_challenge(www_authenticate, http_client: http_client, timeout: [:timeout]) authenticate(issuer: issuer, http_client: http_client, **) 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.
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.(, 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() 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.
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.
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.(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.(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.
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.
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.
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.
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 |