Commit 0e5426a

mo khan <mo@mokhan.ca>
2025-03-18 02:36:48
feat: exchange an authorization grant for a token
1 parent 2cf2473
Changed files (3)
bin/idp
@@ -378,6 +378,50 @@ module Authz
     end
   end
 
+  class AuthorizationGrant
+    class << self
+      def all
+        @all ||= []
+      end
+
+      # TODO:: Look up saml_assertion
+      def find_by(params)
+        case params[:grant_type]
+        when "authorization_code"
+          # TODO:: implement `code_verifier` param
+          all.find do |grant|
+            grant.code == params[:code]
+          end
+        end
+      end
+
+      def create!(user)
+        new(user).tap do |grant|
+          all << grant
+        end
+      end
+    end
+
+    attr_reader :code, :user
+
+    def initialize(user)
+      @user = user
+      @code = SecureRandom.uuid
+      @exchanged_at = nil
+    end
+
+    def used?
+      @exchanged_at
+    end
+
+    def create_access_token
+      raise "Invalid code" if used?
+
+      @exchanged_at = Time.now
+      user.create_access_token
+    end
+  end
+
   class OAuthController
     include ::HTTPHelpers
 
@@ -407,10 +451,13 @@ module Authz
         when "/oauth/authorize" # RFC-6749
           return post_authorize(request)
         when "/oauth/token" # RFC-6749
-          # TODO:: Look up authorization grant by (code, saml_assertion)
-          user = Authn::User.new(id: SecureRandom.uuid)
-          return [200, { 'Content-Type' => "application/json" }, [JSON.pretty_generate({
-            access_token: user.create_access_token.to_jwt,
+          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)
+          puts params.inspect
+          grant = AuthorizationGrant.find_by(params)
+
+          return [404, { "Content-Type" => "application/json" }, [JSON.pretty_generate({ error: 404, message: "Not Found" })]] if grant.nil? || grant.used?
+          return [200, { "Content-Type" => "application/json" }, [JSON.pretty_generate({
+            access_token: grant.create_access_token.to_jwt,
             token_type: "Bearer",
             issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
             expires_in: 3600,
@@ -453,13 +500,15 @@ module Authz
 
     def post_authorize(request)
       params = request.params.slice('client_id', 'redirect_uri', 'response_type', 'response_mode', 'state', 'code_challenge_method', 'code_challenge', 'scope')
+
+      grant = AuthorizationGrant.create!(current_user(request))
       case params['response_type']
       when 'code'
         case params['response_mode']
         when 'fragment'
-          return [302, { 'Location' => "#{params['redirect_uri']}#code=#{SecureRandom.uuid}&state=#{params['state']}" }, []]
+          return [302, { 'Location' => "#{params['redirect_uri']}#code=#{grant.code}&state=#{params['state']}" }, []]
         when 'query'
-          return [302, { 'Location' => "#{params['redirect_uri']}?code=#{SecureRandom.uuid}&state=#{params['state']}" }, []]
+          return [302, { 'Location' => "#{params['redirect_uri']}?code=#{grant.code}&state=#{params['state']}" }, []]
         else
           # TODO:: form post
         end
test/e2e_test.go
@@ -95,6 +95,20 @@ func TestAuthx(t *testing.T) {
 				assert.NotNil(t, organizations)
 			})
 
+			t.Run("GET http://api.example.com:8080/groups.json", func(t *testing.T) {
+				response := x.Must(http.Get("http://api.example.com:8080/groups.json"))
+				assert.Equal(t, http.StatusForbidden, response.StatusCode)
+			})
+
+			t.Run("GET http://api.example.com:8080/groups.json with Authorization", func(t *testing.T) {
+				request := x.Must(http.NewRequestWithContext(t.Context(), "GET", "http://api.example.com:8080/groups.json", nil))
+				request.Header.Add("Authorization", "Bearer "+item.AccessToken)
+				response := x.Must(client.Do(request))
+				require.Equal(t, http.StatusOK, response.StatusCode)
+				groups := x.Must(serde.FromJSON[[]map[string]string](response.Body))
+				assert.NotNil(t, groups)
+			})
+
 			t.Run("GET http://api.example.com:8080/projects.json", func(t *testing.T) {
 				response := x.Must(http.Get("http://api.example.com:8080/projects.json"))
 				assert.Equal(t, http.StatusForbidden, response.StatusCode)
@@ -242,6 +256,13 @@ func TestAuthx(t *testing.T) {
 			assert.Equal(t, "Bearer", credentials.TokenType)
 			assert.NotEmpty(t, credentials.RefreshToken)
 
+			t.Run("cannot re-use the same authorization grant", func(t *testing.T) {
+				newCredentials, err := conf.Exchange(ctx, code)
+
+				assert.Error(t, err)
+				assert.Empty(t, newCredentials)
+			})
+
 			t.Run("token is usable against REST API", func(t *testing.T) {
 				client := conf.Client(ctx, credentials)
 				response := x.Must(client.Get("http://api.example.com:8080/projects.json"))
policy.csv
@@ -1,8 +1,8 @@
-p, "\Agid:\/\/[a-z]+\/[A-Za-z:]+\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\z", api.example.com, (GET)|(POST)|(PATCH)|(PUT)|(DELETE)|(HEAD), /*.json
+p, "\Agid:\/\/[a-z]+\/[A-Za-z:]+\/\d+\z", api.example.com, (GET)|(POST)|(PATCH)|(PUT)|(DELETE)|(HEAD), /*.json
 p, *, *, (GET)|(HEAD), /health
 p, *, *, GET, /.well-known/*
 p, *, *, GET, /favicon.ico
-p, "\Agid:\/\/[a-z]+\/[A-Za-z:]+\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\z", idp.example.com, (GET)|(POST)|(PATCH)|(PUT)|(DELETE)|(HEAD), /twirp/authx.rpc.*
+p, "\Agid:\/\/[a-z]+\/[A-Za-z:]+\/\d+\z", idp.example.com, (GET)|(POST)|(PATCH)|(PUT)|(DELETE)|(HEAD), /twirp/authx.rpc.*
 p, *, idp.example.com, (GET)|(POST), /oauth*
 p, *, idp.example.com, (GET)|(POST), /saml*
 p, *, idp.example.com, (GET)|(POST), /sessions*