Module: Wurk::API::Auth
- Defined in:
- lib/wurk/api/auth.rb
Overview
Bearer-token authentication and scope checks for the machine-facing HTTP API. Nothing behind /v1 answers without passing through here.
Deliberately a separate plane from the dashboard's. That one is cookie-
authenticated and CSRF-guarded by Wurk::SameOriginGuard, which denies any
request that doesn't carry Sec-Fetch-Site: same-origin — a header the
browser sets and a Python or Go producer never sends. Reusing it would
mean either 403ing every legitimate machine client or relaxing the one
check that stops a cross-site page from driving a logged-in operator's
dashboard.
Defined Under Namespace
Classes: Principal
Constant Summary collapse
- SCOPES =
admingrants the other two. An operator token allowed to stop a process but refused a queue listing would be a surprise, not a safeguard. %i[enqueue read admin].freeze
- ANY =
Required scope for a route every authenticated client needs whatever it was granted — the discovery document, which an enqueue-only producer has to read to confirm the contract it is about to post to.
:any- ROUTE_SCOPES =
(SCOPES + [ANY]).freeze
- MIN_TOKEN_LENGTH =
Under 20 characters the host almost certainly typed the token instead of generating one (
SecureRandom.urlsafe_base64(16)is 22). A guessable token on this API is remote code selection, not just a data leak. 20- TOKEN_FORMAT =
Printable ASCII, no spaces — what an Authorization header carries intact. A token that loses a byte in transit fails as "wrong token", the least debuggable 401 there is, so reject the shape up front.
/\A[\x21-\x7e]+\z/- BEARER =
RFC 6750 §2.1. The scheme is case-insensitive (RFC 9110 §11.1); the credential is not.
/\ABearer[ \t]+([\x21-\x7e]+)[ \t]*\z/i- REALM =
'Bearer realm="wurk"'- FINGERPRINT_DOMAIN =
Domain-separated so the label below is not a digest of the raw token — a value that leaked into a log would otherwise be a free head start on confirming a guessed token offline.
'wurk.api.token.'- FINGERPRINT_LENGTH =
128 bits of the digest. Long enough that two credentials never share a label, short enough to read in a log line.
32
Class Method Summary collapse
-
.authenticate(request, config) ⇒ Principal?
Nil when no credential was presented, or the one presented matches nothing registered.
- .bearer(header) ⇒ Object
-
.configured?(config) ⇒ Boolean
The gate the whole API hangs off.
-
.credential!(token, scopes) ⇒ Array(String, Array<Symbol>)
Validates a credential where it is declared, so a typo raises in the initializer that wrote it instead of surfacing as a 401 in production.
- .fingerprint(token) ⇒ Object
-
.forbidden(request, scope) ⇒ Object
403, not a 404 hiding the route: the client already authenticated, so concealing the address buys nothing and costs it the one fact it needs — which scope to ask its operator for.
- .granted!(scopes) ⇒ Object
-
.lookup(presented, tokens) ⇒ Object
Walks every registered token with no early return on a hit, so the clock can't tell a client where in the table its guess landed — or whether it landed at all.
-
.unauthorized(request) ⇒ Object
RFC 6750 §3.
Class Method Details
.authenticate(request, config) ⇒ Principal?
Returns nil when no credential was presented, or the one presented matches nothing registered.
106 107 108 109 110 111 112 |
# File 'lib/wurk/api/auth.rb', line 106 def authenticate(request, config) presented = bearer(request.get_header('HTTP_AUTHORIZATION')) return nil unless presented token, scopes = lookup(presented, config.api_tokens) scopes && Principal.new(fingerprint(token), scopes) end |
.bearer(header) ⇒ Object
114 115 116 117 |
# File 'lib/wurk/api/auth.rb', line 114 def bearer(header) match = BEARER.match(header.to_s) match && match[1] end |
.configured?(config) ⇒ Boolean
The gate the whole API hangs off. No token registered → the engine never mounts the app, and an app mounted directly answers 404 rather than advertising a surface with nothing behind it.
74 75 76 |
# File 'lib/wurk/api/auth.rb', line 74 def configured?(config) !config.api_tokens.empty? end |
.credential!(token, scopes) ⇒ Array(String, Array<Symbol>)
Validates a credential where it is declared, so a typo raises in the initializer that wrote it instead of surfacing as a 401 in production.
82 83 84 85 86 87 88 89 90 |
# File 'lib/wurk/api/auth.rb', line 82 def credential!(token, scopes) value = token.to_s unless value.length >= MIN_TOKEN_LENGTH raise ArgumentError, "api_token must be at least #{MIN_TOKEN_LENGTH} characters" end raise ArgumentError, 'api_token must be printable ASCII with no spaces' unless TOKEN_FORMAT.match?(value) [value, granted!(scopes)] end |
.fingerprint(token) ⇒ Object
134 135 136 |
# File 'lib/wurk/api/auth.rb', line 134 def fingerprint(token) ::Digest::SHA256.hexdigest("#{FINGERPRINT_DOMAIN}#{token}")[0, FINGERPRINT_LENGTH] end |
.forbidden(request, scope) ⇒ Object
403, not a 404 hiding the route: the client already authenticated, so concealing the address buys nothing and costs it the one fact it needs — which scope to ask its operator for.
155 156 157 158 159 160 161 162 163 164 |
# File 'lib/wurk/api/auth.rb', line 155 def forbidden(request, scope) Problem.render( Problem::INSUFFICIENT_SCOPE, status: 403, detail: "This token is not granted the #{scope} scope.", instance: request.path, headers: { 'www-authenticate' => %(#{REALM}, error="insufficient_scope", scope="#{scope}") }, required_scope: scope ) end |
.granted!(scopes) ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/wurk/api/auth.rb', line 92 def granted!(scopes) granted = Array(scopes).map { |scope| scope.to_s.to_sym }.uniq raise ArgumentError, 'api_token requires at least one scope' if granted.empty? unknown = granted - SCOPES unless unknown.empty? raise ArgumentError, "unknown api_token scope #{unknown.inspect}; valid scopes are #{SCOPES.inspect}" end granted.freeze end |
.lookup(presented, tokens) ⇒ Object
Walks every registered token with no early return on a hit, so the clock can't tell a client where in the table its guess landed — or whether it landed at all. Both sides are digested first: OpenSSL's compare demands equal lengths, and the bytesize pre-check the obvious implementation reaches for (Rack::Utils.secure_compare does exactly that) answers "how long is the real token" to anyone who can time this.
125 126 127 128 129 130 131 132 |
# File 'lib/wurk/api/auth.rb', line 125 def lookup(presented, tokens) digest = ::Digest::SHA256.digest(presented) found = nil tokens.each do |token, scopes| found = [token, scopes] if OpenSSL.fixed_length_secure_compare(digest, ::Digest::SHA256.digest(token)) end found end |
.unauthorized(request) ⇒ Object
RFC 6750 §3. The challenge names invalid_token only when the client
actually presented one — telling a client that sent no credential that
its credential was rejected sends it hunting for the wrong bug.
141 142 143 144 145 146 147 148 149 150 |
# File 'lib/wurk/api/auth.rb', line 141 def (request) presented = request.get_header('HTTP_AUTHORIZATION') Problem.render( Problem::UNAUTHORIZED, status: 401, detail: presented ? 'The bearer token presented is not valid.' : 'A bearer token is required.', instance: request.path, headers: { 'www-authenticate' => presented ? %(#{REALM}, error="invalid_token") : REALM } ) end |