Commit a255f98

mo khan <mo@mokhan.ca>
2025-03-18 03:38:32
feat: add a token introspection endpoint
1 parent 66edae4
Changed files (2)
bin/idp
@@ -105,7 +105,7 @@ module Authn
     end
 
     def create_access_token
-      ::Authz::JWT.new(sub: to_global_id.to_s, iat: Time.now.to_i)
+      ::Authz::JWT.new(sub: to_global_id.to_s)
     end
 
     def assertion_attributes_for(request)
@@ -323,10 +323,30 @@ module Authz
   end
 
   class JWT
+    class << self
+      # TODO:: validate signature
+      def decode(encoded)
+        _header, body, _signature = encoded
+          .split('.', 3)
+          .map { |x| JSON.parse(Base64.strict_decode64(x), symbolize_names: true) rescue {} }
+        new(body)
+      end
+    end
+
     attr_reader :claims
 
     def initialize(claims)
-      @claims = claims
+      now = Time.now.to_i
+      @claims = {
+        iat: now,
+        nbf: now,
+        jti: SecureRandom.uuid,
+      }.merge(claims)
+    end
+
+    def active?
+      # TODO:: check if current time is within valid range
+      true
     end
 
     def to_jwt
@@ -360,21 +380,14 @@ module Authz
         false
       end
 
-      def subject_of(token)
-        _header, claims, _signature = from_jwt(token)
-        claims[:sub]
+      def subject_of(encoded_token)
+        token = ::Authz::JWT.decode(encoded_token)
+        token&.claims[:sub]
       end
 
       def resource_from(global_id)
         GlobalID::Locator.locate(global_id)
       end
-
-      # TODO:: validate signature
-      def from_jwt(token)
-        token
-          .split('.', 3)
-          .map { |x| JSON.parse(Base64.strict_decode64(x), symbolize_names: true) rescue {} }
-      end
     end
   end
 
@@ -499,6 +512,9 @@ module Authz
         case request.path
         when "/oauth/authorize" # RFC-6749
           return post_authorize(request)
+        when "/oauth/introspect" # RFC-7662
+          params = request.content_type == "application/json" ? JSON.parse(request.body.read, symbolize_names: true) : Hash[URI.decode_www_form(request.body.read)].transform_keys(&:to_sym)
+          return post_introspect(params.slice(:token, :token_type_hint))
         when "/oauth/token" # RFC-6749
           params = request.content_type == "application/json" ? JSON.parse(request.body.read, symbolize_names: true) : Hash[URI.decode_www_form(request.body.read)].transform_keys(&:to_sym)
           grant = AuthorizationGrant.find_by(params)
@@ -515,6 +531,13 @@ module Authz
       http_not_found
     end
 
+    private
+
+    def post_introspect(params)
+      token = ::Authz::JWT.decode(params[:token])
+      return [200, { "Content-Type" => "application/json" }, [JSON.pretty_generate(token.claims.merge(active: token.active?))]]
+    end
+
     def get_authorize(oauth_params)
       template = <<~ERB
         <!DOCTYPE html>
test/e2e_test.go
@@ -277,6 +277,19 @@ func TestAuthx(t *testing.T) {
 				project := x.Must(serde.FromJSON[map[string]string](response.Body))
 				assert.Equal(t, "foo", project["name"])
 			})
+
+			t.Run("token can be introspected", func(t *testing.T) {
+				client := conf.Client(ctx, credentials)
+
+				io := bytes.NewBuffer(nil)
+				assert.NoError(t, serde.ToJSON(io, map[string]string{"token": credentials.AccessToken}))
+				response := x.Must(client.Post("http://idp.example.com:8080/oauth/introspect", "application/json", io))
+				require.Equal(t, http.StatusOK, response.StatusCode)
+
+				claims := x.Must(serde.FromJSON[map[string]interface{}](response.Body))
+				assert.Equal(t, true, claims["active"])
+				assert.Equal(t, "gid://example/Authn::User/1", claims["sub"])
+			})
 		})
 	})
 }