From 0e5426ad0026d52a44dd7c0e76a894860022bb34 Mon Sep 17 00:00:00 2001 From: mo khan Date: Mon, 17 Mar 2025 20:36:48 -0600 Subject: feat: exchange an authorization grant for a token --- bin/idp | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++------ policy.csv | 4 ++-- test/e2e_test.go | 21 +++++++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/bin/idp b/bin/idp index 622faa19..23510bd2 100755 --- a/bin/idp +++ b/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 diff --git a/policy.csv b/policy.csv index ce661d95..f2b5062d 100644 --- a/policy.csv +++ b/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* diff --git a/test/e2e_test.go b/test/e2e_test.go index 7fd59e4a..c88f5fc6 100644 --- a/test/e2e_test.go +++ b/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")) -- cgit v1.2.3