diff --git a/.rubocop.yml b/.rubocop.yml
index 19a7a710c..2875b5501 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -23,6 +23,7 @@ AllCops:
- "app/migration/**/*"
- "tmp/**/*"
- "sandbox/**/*"
+ - "config/initializers/doorkeeper.rb"
FactoryBot/FactoryAssociationWithStrategy:
Enabled: false
diff --git a/Gemfile b/Gemfile
index 381004752..04b9d56e4 100644
--- a/Gemfile
+++ b/Gemfile
@@ -9,6 +9,8 @@ gem 'caxlsx'
gem 'aws-sdk-s3', require: false
gem 'bootsnap', require: false
gem 'csv'
+gem 'doorkeeper'
+gem 'doorkeeper-i18n'
gem 'draper'
gem 'emailable'
gem 'importmap-rails', '~> 1.0'
@@ -83,4 +85,5 @@ group :test do
gem 'rspec-rails', '7.1.0'
gem 'simplecov', require: false
gem 'webmock'
+ gem 'openapi3_parser'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 9510089ef..afbae3927 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -138,6 +138,9 @@ GEM
nokogiri (~> 1.10, >= 1.10.4)
rubyzip (>= 1.3.0, < 3)
coderay (1.1.3)
+ commonmarker (2.0.2.1-aarch64-linux)
+ commonmarker (2.0.2.1-arm64-darwin)
+ commonmarker (2.0.2.1-x86_64-linux)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
crack (1.0.0)
@@ -187,6 +190,10 @@ GEM
reline (>= 0.3.8)
diff-lcs (1.5.1)
docile (1.4.0)
+ doorkeeper (5.7.1)
+ railties (>= 5)
+ doorkeeper-i18n (5.2.7)
+ doorkeeper (>= 5.2)
draper (4.0.2)
actionpack (>= 5.0)
activemodel (>= 5.0)
@@ -388,6 +395,8 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
+ openapi3_parser (0.10.0)
+ commonmarker (>= 1.0)
ostruct (0.6.1)
parallel (1.26.3)
parser (3.3.7.0)
@@ -630,6 +639,8 @@ DEPENDENCIES
cuprite
database_cleaner-active_record
debug
+ doorkeeper
+ doorkeeper-i18n
draper
emailable
factory_bot_rails
@@ -658,6 +669,7 @@ DEPENDENCIES
omniauth
omniauth-oauth2
omniauth-rails_csrf_protection
+ openapi3_parser
pg (~> 1.5)
propshaft
puma (>= 5.0)
diff --git a/app/controllers/api/frontal_controller.rb b/app/controllers/api/frontal_controller.rb
index 005cef4e6..420111998 100644
--- a/app/controllers/api/frontal_controller.rb
+++ b/app/controllers/api/frontal_controller.rb
@@ -1,4 +1,4 @@
-class API::FrontalController < APIController
+class API::FrontalController < ActionController::API
def index
if frontal?
render json: {
diff --git a/app/controllers/api/v1/authorization_requests_controller.rb b/app/controllers/api/v1/authorization_requests_controller.rb
new file mode 100644
index 000000000..19a335659
--- /dev/null
+++ b/app/controllers/api/v1/authorization_requests_controller.rb
@@ -0,0 +1,36 @@
+class API::V1::AuthorizationRequestsController < API::V1Controller
+ def index
+ authorization_requests = AuthorizationRequest
+ .where(type: valid_authorization_request_types)
+ .offset(params[:offset])
+ .limit(params.fetch(:limit, 10))
+
+ if authorization_requests.any?
+ render json: authorization_requests,
+ each_serializer: API::V1::AuthorizationRequestSerializer,
+ status: :ok
+ else
+ render_error(404, title: 'Non trouvé', detail: 'Aucune demande n\'a été trouvé')
+ end
+ end
+
+ def show
+ authorization_request = AuthorizationRequest
+ .where(type: valid_authorization_request_types)
+ .find(params[:id])
+
+ render json: authorization_request,
+ serializer: API::V1::AuthorizationRequestSerializer,
+ status: :ok
+ rescue ActiveRecord::RecordNotFound
+ render_error(404, title: 'Non trouvé', detail: 'Aucune demande n\'a été trouvé')
+ end
+
+ private
+
+ def valid_authorization_request_types
+ current_user.developer_roles.map do |role|
+ "AuthorizationRequest::#{role.split(':')[0].classify}"
+ end
+ end
+end
diff --git a/app/controllers/api/v1/credentials_controller.rb b/app/controllers/api/v1/credentials_controller.rb
new file mode 100644
index 000000000..4bac1d65e
--- /dev/null
+++ b/app/controllers/api/v1/credentials_controller.rb
@@ -0,0 +1,5 @@
+class API::V1::CredentialsController < API::V1Controller
+ def me
+ render json: current_user.attributes.slice('id', 'email', 'family_name', 'given_name', 'job_title')
+ end
+end
diff --git a/app/controllers/api/v1_controller.rb b/app/controllers/api/v1_controller.rb
new file mode 100644
index 000000000..1647c6bd3
--- /dev/null
+++ b/app/controllers/api/v1_controller.rb
@@ -0,0 +1,24 @@
+class API::V1Controller < APIController
+ rescue_from Doorkeeper::Errors::TokenUnknown, with: :render_unauthorized_error
+
+ before_action :doorkeeper_authorize!
+
+ protected
+
+ def render_unauthorized_error
+ render_error(401, title: 'Non autorisé', detail: "Un jeton d'accès est requis mais absent dans les en-têtes de la requête.", source: { pointer: 'headers/Authorization' })
+ end
+
+ def render_error(http_code, title:, detail:, source: nil)
+ render json: {
+ errors: [
+ {
+ status: http_code.to_s,
+ title:,
+ detail:,
+ source:,
+ }.compact
+ ]
+ }, status: http_code
+ end
+end
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index 19d0ffe0f..8502ff484 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -1,2 +1,12 @@
class APIController < ActionController::API
+ include Authentication
+
+ api_mode!
+
+ def user_id_session
+ {
+ 'value' => doorkeeper_token.try(:resource_owner_id),
+ 'expires_at' => (doorkeeper_token.try(:expires_in) || 0) + Time.current.to_i,
+ }
+ end
end
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index f27246691..10e904508 100644
--- a/app/controllers/concerns/authentication.rb
+++ b/app/controllers/concerns/authentication.rb
@@ -4,13 +4,15 @@ module Authentication
included do
before_action :authenticate_user!
- helper_method :current_user, :current_organization, :user_signed_in?
+ helper_method :current_user, :current_organization, :user_signed_in? if respond_to?(:helper_method)
end
class_methods do
def allow_unauthenticated_access(**)
skip_before_action(:authenticate_user!, **)
end
+
+ alias_method :api_mode!, :allow_unauthenticated_access
end
def sign_in_path
@@ -52,10 +54,14 @@ def user_signed_in?
end
def valid_user_session?
- session[:user_id].present? &&
- session[:user_id]['value'].present? &&
- session[:user_id]['expires_at'].present? &&
- session[:user_id]['expires_at'] > Time.current
+ user_id_session.present? &&
+ user_id_session['value'].present? &&
+ user_id_session['expires_at'].present? &&
+ user_id_session['expires_at'] > Time.current
+ end
+
+ def user_id_session
+ session[:user_id]
end
def save_redirect_path
@@ -63,7 +69,7 @@ def save_redirect_path
end
def current_user
- @current_user ||= User.find_by(id: session[:user_id].try(:[], 'value'))
+ @current_user ||= User.find_by(id: user_id_session.try(:[], 'value'))
end
def current_organization
diff --git a/app/controllers/open_api_controller.rb b/app/controllers/open_api_controller.rb
new file mode 100644
index 000000000..79708706b
--- /dev/null
+++ b/app/controllers/open_api_controller.rb
@@ -0,0 +1,9 @@
+class OpenAPIController < ApplicationController
+ include Authentication
+
+ allow_unauthenticated_access
+
+ layout 'application'
+
+ def show; end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 6f6bba7f9..62aae96da 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -39,6 +39,7 @@ class User < ApplicationRecord
)",
[
"#{authorization_request_type.underscore}:instructor",
+ "#{authorization_request_type.underscore}:developer",
"#{authorization_request_type.underscore}:reporter",
]
)
@@ -46,6 +47,23 @@ class User < ApplicationRecord
add_instruction_boolean_settings :submit_notifications, :messages_notifications
+ has_many :oauth_applications,
+ class_name: 'Doorkeeper::Application',
+ as: :owner,
+ dependent: :restrict_with_exception
+
+ has_many :access_grants,
+ class_name: 'Doorkeeper::AccessGrant',
+ foreign_key: :resource_owner_id,
+ inverse_of: :resource_owner,
+ dependent: :delete_all
+
+ has_many :access_tokens,
+ class_name: 'Doorkeeper::AccessToken',
+ foreign_key: :resource_owner_id,
+ inverse_of: :resource_owner,
+ dependent: :delete_all
+
def full_name
if family_name.present? && given_name.present?
"#{family_name} #{given_name}"
@@ -65,13 +83,15 @@ def instructor?(authorization_request_type = nil)
def reporter_roles
(roles.select { |role|
role.end_with?(':reporter')
- } + instructor_roles).uniq
+ } + instructor_roles + developer_roles).uniq
end
def instructor_roles
- roles.select do |role|
- role.end_with?(':instructor')
- end
+ roles.select { |role| role.end_with?(':instructor') }
+ end
+
+ def developer_roles
+ roles.select { |role| role.end_with?(':developer') }
end
def reporter?(authorization_request_type = nil)
diff --git a/app/serializers/api/v1/authorization_request_serializer.rb b/app/serializers/api/v1/authorization_request_serializer.rb
new file mode 100644
index 000000000..4bc223354
--- /dev/null
+++ b/app/serializers/api/v1/authorization_request_serializer.rb
@@ -0,0 +1 @@
+class API::V1::AuthorizationRequestSerializer < WebhookAuthorizationRequestSerializer; end
diff --git a/app/serializers/webhook_authorization_request_serializer.rb b/app/serializers/webhook_authorization_request_serializer.rb
index 2e8c4a672..cac7a7ee1 100644
--- a/app/serializers/webhook_authorization_request_serializer.rb
+++ b/app/serializers/webhook_authorization_request_serializer.rb
@@ -11,7 +11,11 @@ class WebhookAuthorizationRequestSerializer < ApplicationSerializer
def data
object.data.keys.index_with { |key|
- object.public_send(key)
- }.symbolize_keys
+ begin
+ object.public_send(key)
+ rescue NoMethodError
+ nil
+ end
+ }.compact.symbolize_keys
end
end
diff --git a/app/views/open_api/show.html.erb b/app/views/open_api/show.html.erb
new file mode 100644
index 000000000..25f789b3b
--- /dev/null
+++ b/app/views/open_api/show.html.erb
@@ -0,0 +1,29 @@
+
+
+
+
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
new file mode 100644
index 000000000..8871d76e6
--- /dev/null
+++ b/config/initializers/doorkeeper.rb
@@ -0,0 +1,528 @@
+# frozen_string_literal: true
+
+Doorkeeper.configure do
+ # Change the ORM that doorkeeper will use (requires ORM extensions installed).
+ # Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms
+ orm :active_record
+
+ # This block will be called to check whether the resource owner is authenticated or not.
+ # resource_owner_authenticator do
+ # if user_signed_in?
+ # current_user
+ # else
+ # redirect_to(sign_in_path)
+ # end
+ # end
+
+ # If you didn't skip applications controller from Doorkeeper routes in your application routes.rb
+ # file then you need to declare this block in order to restrict access to the web interface for
+ # adding oauth authorized applications. In other case it will return 403 Forbidden response
+ # every time somebody will try to access the admin web interface.
+ #
+ # admin_authenticator do
+ # if user_signed_in? && current_user.admin?
+ # current_user
+ # else
+ # head :forbidden
+ # end
+ # end
+
+ # You can use your own model classes if you need to extend (or even override) default
+ # Doorkeeper models such as `Application`, `AccessToken` and `AccessGrant.
+ #
+ # By default Doorkeeper ActiveRecord ORM uses its own classes:
+ #
+ # access_token_class "Doorkeeper::AccessToken"
+ # access_grant_class "Doorkeeper::AccessGrant"
+ # application_class "Doorkeeper::Application"
+ #
+ # Don't forget to include Doorkeeper ORM mixins into your custom models:
+ #
+ # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken - for access token
+ # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant - for access grant
+ # * ::Doorkeeper::Orm::ActiveRecord::Mixins::Application - for application (OAuth2 clients)
+ #
+ # For example:
+ #
+ # access_token_class "MyAccessToken"
+ #
+ # class MyAccessToken < ApplicationRecord
+ # include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken
+ #
+ # self.table_name = "hey_i_wanna_my_name"
+ #
+ # def destroy_me!
+ # destroy
+ # end
+ # end
+
+ # Enables polymorphic Resource Owner association for Access Tokens and Access Grants.
+ # By default this option is disabled.
+ #
+ # Make sure you properly setup you database and have all the required columns (run
+ # `bundle exec rails generate doorkeeper:enable_polymorphic_resource_owner` and execute Rails
+ # migrations).
+ #
+ # If this option enabled, Doorkeeper will store not only Resource Owner primary key
+ # value, but also it's type (class name). See "Polymorphic Associations" section of
+ # Rails guides: https://guides.rubyonrails.org/association_basics.html#polymorphic-associations
+ #
+ # [NOTE] If you apply this option on already existing project don't forget to manually
+ # update `resource_owner_type` column in the database and fix migration template as it will
+ # set NOT NULL constraint for Access Grants table.
+ #
+ # use_polymorphic_resource_owner
+
+ # If you are planning to use Doorkeeper in Rails 5 API-only application, then you might
+ # want to use API mode that will skip all the views management and change the way how
+ # Doorkeeper responds to a requests.
+ #
+ api_only
+
+ # Enforce token request content type to application/x-www-form-urlencoded.
+ # It is not enabled by default to not break prior versions of the gem.
+ #
+ # enforce_content_type
+
+ # Authorization Code expiration time (default: 10 minutes).
+ #
+ # authorization_code_expires_in 10.minutes
+
+ # Access token expiration time (default: 2 hours).
+ # If you set this to `nil` Doorkeeper will not expire the token and omit expires_in in response.
+ # It is RECOMMENDED to set expiration time explicitly.
+ # Prefer access_token_expires_in 100.years or similar,
+ # which would be functionally equivalent and avoid the risk of unexpected behavior by callers.
+ #
+ # access_token_expires_in 2.hours
+
+ # Assign custom TTL for access tokens. Will be used instead of access_token_expires_in
+ # option if defined. In case the block returns `nil` value Doorkeeper fallbacks to
+ # +access_token_expires_in+ configuration option value. If you really need to issue a
+ # non-expiring access token (which is not recommended) then you need to return
+ # Float::INFINITY from this block.
+ #
+ # `context` has the following properties available:
+ #
+ # * `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
+ # * `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
+ # * `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
+ # * `resource_owner` - authorized resource owner instance (if present)
+ #
+ # custom_access_token_expires_in do |context|
+ # context.client.additional_settings.implicit_oauth_expiration
+ # end
+
+ # Use a custom class for generating the access token.
+ # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-access-token-generator
+ #
+ # access_token_generator '::Doorkeeper::JWT'
+
+ # The controller +Doorkeeper::ApplicationController+ inherits from.
+ # Defaults to +ActionController::Base+ unless +api_only+ is set, which changes the default to
+ # +ActionController::API+. The return value of this option must be a stringified class name.
+ # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-controllers
+ #
+ base_controller 'APIController'
+
+ # Reuse access token for the same resource owner within an application (disabled by default).
+ #
+ # This option protects your application from creating new tokens before old **valid** one becomes
+ # expired so your database doesn't bloat. Keep in mind that when this option is enabled Doorkeeper
+ # doesn't update existing token expiration time, it will create a new token instead if no active matching
+ # token found for the application, resources owner and/or set of scopes.
+ # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383
+ #
+ # You can not enable this option together with +hash_token_secrets+.
+ #
+ # reuse_access_token
+
+ # In case you enabled `reuse_access_token` option Doorkeeper will try to find matching
+ # token using `matching_token_for` Access Token API that searches for valid records
+ # in batches in order not to pollute the memory with all the database records. By default
+ # Doorkeeper uses batch size of 10 000 records. You can increase or decrease this value
+ # depending on your needs and server capabilities.
+ #
+ # token_lookup_batch_size 10_000
+
+ # Set a limit for token_reuse if using reuse_access_token option
+ #
+ # This option limits token_reusability to some extent.
+ # If not set then access_token will be reused unless it expires.
+ # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/1189
+ #
+ # This option should be a percentage(i.e. (0,100])
+ #
+ # token_reuse_limit 100
+
+ # Only allow one valid access token obtained via client credentials
+ # per client. If a new access token is obtained before the old one
+ # expired, the old one gets revoked (disabled by default)
+ #
+ # When enabling this option, make sure that you do not expect multiple processes
+ # using the same credentials at the same time (e.g. web servers spanning
+ # multiple machines and/or processes).
+ #
+ # revoke_previous_client_credentials_token
+
+ # Only allow one valid access token obtained via authorization code
+ # per client. If a new access token is obtained before the old one
+ # expired, the old one gets revoked (disabled by default)
+ #
+ # revoke_previous_authorization_code_token
+
+ # Require non-confidential clients to use PKCE when using an authorization code
+ # to obtain an access_token (disabled by default)
+ #
+ # force_pkce
+
+ # Hash access and refresh tokens before persisting them.
+ # This will disable the possibility to use +reuse_access_token+
+ # since plain values can no longer be retrieved.
+ #
+ # Note: If you are already a user of doorkeeper and have existing tokens
+ # in your installation, they will be invalid without adding 'fallback: :plain'.
+ #
+ # hash_token_secrets
+ # By default, token secrets will be hashed using the
+ # +Doorkeeper::Hashing::SHA256+ strategy.
+ #
+ # If you wish to use another hashing implementation, you can override
+ # this strategy as follows:
+ #
+ # hash_token_secrets using: '::Doorkeeper::Hashing::MyCustomHashImpl'
+ #
+ # Keep in mind that changing the hashing function will invalidate all existing
+ # secrets, if there are any.
+
+ # Hash application secrets before persisting them.
+ #
+ # hash_application_secrets
+ #
+ # By default, applications will be hashed
+ # with the +Doorkeeper::SecretStoring::SHA256+ strategy.
+ #
+ # If you wish to use bcrypt for application secret hashing, uncomment
+ # this line instead:
+ #
+ # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt'
+
+ # When the above option is enabled, and a hashed token or secret is not found,
+ # you can allow to fall back to another strategy. For users upgrading
+ # doorkeeper and wishing to enable hashing, you will probably want to enable
+ # the fallback to plain tokens.
+ #
+ # This will ensure that old access tokens and secrets
+ # will remain valid even if the hashing above is enabled.
+ #
+ # This can be done by adding 'fallback: plain', e.g. :
+ #
+ # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt', fallback: :plain
+
+ # Issue access tokens with refresh token (disabled by default), you may also
+ # pass a block which accepts `context` to customize when to give a refresh
+ # token or not. Similar to +custom_access_token_expires_in+, `context` has
+ # the following properties:
+ #
+ # `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
+ # `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
+ # `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
+ #
+ # use_refresh_token
+
+ # Provide support for an owner to be assigned to each registered application (disabled by default)
+ # Optional parameter confirmation: true (default: false) if you want to enforce ownership of
+ # a registered application
+ # NOTE: you must also run the rails g doorkeeper:application_owner generator
+ # to provide the necessary support
+ #
+ enable_application_owner confirmation: true
+
+ # Define access token scopes for your provider
+ # For more information go to
+ # https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes
+ #
+ default_scopes :public
+ optional_scopes :read_authorization_requests,
+ :write_authorization_requests
+
+ # Allows to restrict only certain scopes for grant_type.
+ # By default, all the scopes will be available for all the grant types.
+ #
+ # Keys to this hash should be the name of grant_type and
+ # values should be the array of scopes for that grant type.
+ # Note: scopes should be from configured_scopes (i.e. default or optional)
+ #
+ # scopes_by_grant_type password: [:write], client_credentials: [:update]
+
+ # Forbids creating/updating applications with arbitrary scopes that are
+ # not in configuration, i.e. +default_scopes+ or +optional_scopes+.
+ # (disabled by default)
+ #
+ enforce_configured_scopes
+
+ # Change the way client credentials are retrieved from the request object.
+ # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
+ # falls back to the `:client_id` and `:client_secret` params from the `params` object.
+ # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
+ # for more information on customization
+ #
+ # client_credentials :from_basic, :from_params
+
+ # Change the way access token is authenticated from the request object.
+ # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
+ # falls back to the `:access_token` or `:bearer_token` params from the `params` object.
+ # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
+ # for more information on customization
+ #
+ # access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param
+
+ # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled
+ # by default in non-development environments). OAuth2 delegates security in
+ # communication to the HTTPS protocol so it is wise to keep this enabled.
+ #
+ # Callable objects such as proc, lambda, block or any object that responds to
+ # #call can be used in order to allow conditional checks (to allow non-SSL
+ # redirects to localhost for example).
+ #
+ # force_ssl_in_redirect_uri !Rails.env.development?
+ #
+ # force_ssl_in_redirect_uri { |uri| uri.host != 'localhost' }
+
+ # Specify what redirect URI's you want to block during Application creation.
+ # Any redirect URI is allowed by default.
+ #
+ # You can use this option in order to forbid URI's with 'javascript' scheme
+ # for example.
+ #
+ # forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == 'javascript' }
+
+ # Allows to set blank redirect URIs for Applications in case Doorkeeper configured
+ # to use URI-less OAuth grant flows like Client Credentials or Resource Owner
+ # Password Credentials. The option is on by default and checks configured grant
+ # types, but you **need** to manually drop `NOT NULL` constraint from `redirect_uri`
+ # column for `oauth_applications` database table.
+ #
+ # You can completely disable this feature with:
+ #
+ # allow_blank_redirect_uri false
+ #
+ # Or you can define your custom check:
+ #
+ # allow_blank_redirect_uri do |grant_flows, client|
+ # client.superapp?
+ # end
+
+ # Specify how authorization errors should be handled.
+ # By default, doorkeeper renders json errors when access token
+ # is invalid, expired, revoked or has invalid scopes.
+ #
+ # If you want to render error response yourself (i.e. rescue exceptions),
+ # set +handle_auth_errors+ to `:raise` and rescue Doorkeeper::Errors::InvalidToken
+ # or following specific errors:
+ #
+ # Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired,
+ # Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown
+ #
+ handle_auth_errors :raise
+ #
+ # If you want to redirect back to the client application in accordance with
+ # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, you can set
+ # +handle_auth_errors+ to :redirect
+ #
+ # handle_auth_errors :redirect
+
+ # Customize token introspection response.
+ # Allows to add your own fields to default one that are required by the OAuth spec
+ # for the introspection response. It could be `sub`, `aud` and so on.
+ # This configuration option can be a proc, lambda or any Ruby object responds
+ # to `.call` method and result of it's invocation must be a Hash.
+ #
+ # custom_introspection_response do |token, context|
+ # {
+ # "sub": "Z5O3upPC88QrAjx00dis",
+ # "aud": "https://protected.example.net/resource",
+ # "username": User.find(token.resource_owner_id).username
+ # }
+ # end
+ #
+ # or
+ #
+ # custom_introspection_response CustomIntrospectionResponder
+
+ # Specify what grant flows are enabled in array of Strings. The valid
+ # strings and the flows they enable are:
+ #
+ # "authorization_code" => Authorization Code Grant Flow
+ # "implicit" => Implicit Grant Flow
+ # "password" => Resource Owner Password Credentials Grant Flow
+ # "client_credentials" => Client Credentials Grant Flow
+ #
+ # If not specified, Doorkeeper enables authorization_code and
+ # client_credentials.
+ #
+ # implicit and password grant flows have risks that you should understand
+ # before enabling:
+ # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.2
+ # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.3
+ #
+ # grant_flows %w[authorization_code client_credentials]
+
+ # Allows to customize OAuth grant flows that +each+ application support.
+ # You can configure a custom block (or use a class respond to `#call`) that must
+ # return `true` in case Application instance supports requested OAuth grant flow
+ # during the authorization request to the server. This configuration +doesn't+
+ # set flows per application, it only allows to check if application supports
+ # specific grant flow.
+ #
+ # For example you can add an additional database column to `oauth_applications` table,
+ # say `t.array :grant_flows, default: []`, and store allowed grant flows that can
+ # be used with this application there. Then when authorization requested Doorkeeper
+ # will call this block to check if specific Application (passed with client_id and/or
+ # client_secret) is allowed to perform the request for the specific grant type
+ # (authorization, password, client_credentials, etc).
+ #
+ # Example of the block:
+ #
+ # ->(flow, client) { client.grant_flows.include?(flow) }
+ #
+ # In case this option invocation result is `false`, Doorkeeper server returns
+ # :unauthorized_client error and stops the request.
+ #
+ # @param allow_grant_flow_for_client [Proc] Block or any object respond to #call
+ # @return [Boolean] `true` if allow or `false` if forbid the request
+ #
+ # allow_grant_flow_for_client do |grant_flow, client|
+ # # `grant_flows` is an Array column with grant
+ # # flows that application supports
+ #
+ # client.grant_flows.include?(grant_flow)
+ # end
+
+ # If you need arbitrary Resource Owner-Client authorization you can enable this option
+ # and implement the check your need. Config option must respond to #call and return
+ # true in case resource owner authorized for the specific application or false in other
+ # cases.
+ #
+ # By default all Resource Owners are authorized to any Client (application).
+ #
+ # authorize_resource_owner_for_client do |client, resource_owner|
+ # resource_owner.admin? || client.owners_allowlist.include?(resource_owner)
+ # end
+
+ # Allows additional data fields to be sent while granting access to an application,
+ # and for this additional data to be included in subsequently generated access tokens.
+ # The 'authorizations/new' page will need to be overridden to include this additional data
+ # in the request params when granting access. The access grant and access token models
+ # will both need to respond to these additional data fields, and have a database column
+ # to store them in.
+ #
+ # Example:
+ # You have a multi-tenanted platform and want to be able to grant access to a specific
+ # tenant, rather than all the tenants a user has access to. You can use this config
+ # option to specify that a ':tenant_id' will be passed when authorizing. This tenant_id
+ # will be included in the access tokens. When a request is made with one of these access
+ # tokens, you can check that the requested data belongs to the specified tenant.
+ #
+ # Default value is an empty Array: []
+ # custom_access_token_attributes [:tenant_id]
+
+ # Hook into the strategies' request & response life-cycle in case your
+ # application needs advanced customization or logging:
+ #
+ # before_successful_strategy_response do |request|
+ # puts "BEFORE HOOK FIRED! #{request}"
+ # end
+ #
+ # after_successful_strategy_response do |request, response|
+ # puts "AFTER HOOK FIRED! #{request}, #{response}"
+ # end
+
+ # Hook into Authorization flow in order to implement Single Sign Out
+ # or add any other functionality. Inside the block you have an access
+ # to `controller` (authorizations controller instance) and `context`
+ # (Doorkeeper::OAuth::Hooks::Context instance) which provides pre auth
+ # or auth objects with issued token based on hook type (before or after).
+ #
+ # before_successful_authorization do |controller, context|
+ # Rails.logger.info(controller.request.params.inspect)
+ #
+ # Rails.logger.info(context.pre_auth.inspect)
+ # end
+ #
+ # after_successful_authorization do |controller, context|
+ # controller.session[:logout_urls] <<
+ # Doorkeeper::Application
+ # .find_by(controller.request.params.slice(:redirect_uri))
+ # .logout_uri
+ #
+ # Rails.logger.info(context.auth.inspect)
+ # Rails.logger.info(context.issued_token)
+ # end
+
+ # Under some circumstances you might want to have applications auto-approved,
+ # so that the user skips the authorization step.
+ # For example if dealing with a trusted application.
+ #
+ # skip_authorization do |resource_owner, client|
+ # client.superapp? or resource_owner.admin?
+ # end
+
+ # Configure custom constraints for the Token Introspection request.
+ # By default this configuration option allows to introspect a token by another
+ # token of the same application, OR to introspect the token that belongs to
+ # authorized client (from authenticated client) OR when token doesn't
+ # belong to any client (public token). Otherwise requester has no access to the
+ # introspection and it will return response as stated in the RFC.
+ #
+ # Block arguments:
+ #
+ # @param token [Doorkeeper::AccessToken]
+ # token to be introspected
+ #
+ # @param authorized_client [Doorkeeper::Application]
+ # authorized client (if request is authorized using Basic auth with
+ # Client Credentials for example)
+ #
+ # @param authorized_token [Doorkeeper::AccessToken]
+ # Bearer token used to authorize the request
+ #
+ # In case the block returns `nil` or `false` introspection responses with 401 status code
+ # when using authorized token to introspect, or you'll get 200 with { "active": false } body
+ # when using authorized client to introspect as stated in the
+ # RFC 7662 section 2.2. Introspection Response.
+ #
+ # Using with caution:
+ # Keep in mind that these three parameters pass to block can be nil as following case:
+ # `authorized_client` is nil if and only if `authorized_token` is present, and vice versa.
+ # `token` will be nil if and only if `authorized_token` is present.
+ # So remember to use `&` or check if it is present before calling method on
+ # them to make sure you doesn't get NoMethodError exception.
+ #
+ # You can define your custom check:
+ #
+ # allow_token_introspection do |token, authorized_client, authorized_token|
+ # if authorized_token
+ # # customize: require `introspection` scope
+ # authorized_token.application == token&.application ||
+ # authorized_token.scopes.include?("introspection")
+ # elsif token.application
+ # # `protected_resource` is a new database boolean column, for example
+ # authorized_client == token.application || authorized_client.protected_resource?
+ # else
+ # # public token (when token.application is nil, token doesn't belong to any application)
+ # true
+ # end
+ # end
+ #
+ # Or you can completely disable any token introspection:
+ #
+ # allow_token_introspection false
+ #
+ # If you need to block the request at all, then configure your routes.rb or web-server
+ # like nginx to forbid the request.
+
+ # WWW-Authenticate Realm (default: "Doorkeeper").
+ #
+ # realm "Doorkeeper"
+end
diff --git a/config/openapi/v1.yaml b/config/openapi/v1.yaml
new file mode 100644
index 000000000..3caed2e8d
--- /dev/null
+++ b/config/openapi/v1.yaml
@@ -0,0 +1,474 @@
+openapi: 3.0.0
+info:
+ title: API DataPass
+ description: |
+ Seule la gestion des demandes d'habilitations (lecture/écriture) est accessible sur cette version.
+
+ L'authentification s'effectue via OAuth2, avec les scopes suivants:
+ - `read_authorization_requests` -> accès en lecture aux demandes d'habilitations
+ - `write_authorization_requests` -> accès en écriture aux demandes d'habilitations
+
+ Ces scopes sont optionnels. Le seul scope par défaut est `public`
+
+ A noter qu'un client OAuth2 est associé à un utilisateur, et que les types de demandes d'habilitations accessibles sont régies par les droits associés à cet utilisateur. Pour qu'un utilisateur puisse manipuler à travers l'API les demandes d'habilitations d'un certain type, il doit être référencé comme étant développeur pour ce type. Pour cela, il faut qu'un administrateur l'ajoute manuellement.
+
+ Le type d'habilitation est associé à la ressource `Definition`, les formulaires eux à la resource `Formulaire`.
+
+ Chaque `Demande` possède une clé `data` qui correspond aux attributs associés à ce type d'habilitation, celles-ci sont défini dans la ressource `Definition->attributs`
+ version: 0.1.0
+servers:
+ - description: Production
+ url: https://v2.datapass.api.gouv.fr/api/v1
+ - description: Staging
+ url: https://staging.v2.datapass.api.gouv.fr/api/v1
+ - description: Sandbox
+ url: https://sandbox.v2.datapass.api.gouv.fr/api/v1
+ - description: Développement (local)
+ url: http://localhost:3000/api/v1
+components:
+ securitySchemes:
+ OAuth2:
+ type: oauth2
+ flows:
+ authorizationCode:
+ authorizationUrl: https://v2.datapass.api.gouv.fr/api/oauth/authorize
+ tokenUrl: https://v2.datapass.api.gouv.fr/api/oauth/token
+ scopes:
+ read_authorization_requests: Accès en lecture sur les demandes d'habilitation autorisés pour l'utilisateur
+ write_authorization_requests: Accès en écriture sur les demandes d'habilitation autorisés pour l'utilisateur
+ public: Scope pour l'accès aux données publiques et de base (/me)
+ schemas:
+ BaseDemande:
+ type: object
+ properties:
+ definition_id:
+ type: string
+ example: api_particulier
+ description: ID technique de la définition du type de demande d'habilitation. La liste est récupérable via le endpoint `/api/v1/definitions`
+ form_id:
+ type: string
+ example: api-particulier
+ description: ID technique du formulaire. La liste est récupérable via le endpoint `/api/v1/definitions/{definition_id}/formulaires`
+ data:
+ type: object
+ additionalProperties: true
+ description: Données associées à la demande. La liste des clés acceptées est régie par le type de définition, dans la clé `attributs`
+ example:
+ intitule: Ma demande
+ descrition: La description de la demande
+ scopes:
+ - cnaf_quotient_familial
+ - cnaf_allocataires
+ status:
+ type: string
+ example: draft
+ description: Status de la demande
+ enum:
+ - draft
+ - submitted
+ - changes_requested
+ - validated
+ - refused
+ - archived
+ - revoked
+
+ Demande:
+ description: Demande d'habilitation
+ allOf:
+ - type: object
+ properties:
+ id:
+ type: integer
+ example: 51
+ description: Identifiant technique de la demande
+ public_id:
+ type: string
+ format: uuid
+ example: 33e18ac4-8dc3-456d-8985-01871d80fec2
+ description: Identifiant public de la demande. A l'aide de cet identifiant il est possible de consulter la demande sans être connecté. Le chemin est `/public/demandes/{public_id}`.
+ applicant:
+ $ref: '#/components/schemas/Utilisateur'
+ organization:
+ $ref: '#/components/schemas/Organisation'
+ - $ref: '#/components/schemas/BaseDemande'
+
+ CreationDemande:
+ description: Payload pour la création d'une demande d'habilitation
+ allOf:
+ - $ref: '#/components/schemas/BaseDemande'
+ - type: object
+ properties:
+ organization_siret:
+ type: string
+ example: '13002526500013'
+ description: Numéro de siret de l'organisation a associer à la demande
+ applicant_email:
+ type: string
+ example: utilisateur@gouv.fr
+ description: Email du demandeur a associer à la demande
+
+ MiseAJourDemande:
+ description: Payload pour la mise à jour d'une demande d'habilitation
+ type: object
+ properties:
+ data:
+ type: object
+ additionalProperties: true
+ description: Données associées à la demande. La liste des clés acceptées est régie par le type de définition, dans la clé `attributs`
+ example:
+ intitule: Ma demande
+ descrition: La description de la demande
+ scopes:
+ - scope1
+ - scope2
+
+ Utilisateur:
+ type: object
+ description: Utilisateur. L'ensemble des informations, excepté l'identifiant, sont issue de ProConnect.
+ properties:
+ id:
+ type: integer
+ example: 42
+ description: Identifiant technique de l'utilisateur
+ email:
+ type: string
+ example: utilisateur@gouv.fr
+ description: Courriel de l'utilisateur
+ family_name:
+ type: string
+ example: Dupont
+ description: Nom de famille de l'utilisateur
+ nullable: true
+ given_name:
+ type: string
+ example: Jean
+ description: Prénom de l'utilisateur
+ nullable: true
+ job_title:
+ type: string
+ example: Maire
+ description: Intitulé de poste de l'utilisateur
+ nullable: true
+
+ Organisation:
+ type: object
+ description: Organisation. Les informations renvoyées sont tiré de l'INSEE et de ProConnect.
+ properties:
+ id:
+ type: integer
+ example: 51
+ description: Identifiant technique de l'organisation
+ siret:
+ type: string
+ example: '13002526500013'
+ description: Numéro de siret de l'organisation
+ raison_sociale:
+ type: string
+ description: Raison sociale de l'organisation. Cette information provient de l'INSEE
+ example: Direction interministerielle du numerique (DINUM)
+ nullable: true
+
+ Definition:
+ type: object
+ description: Type d'habilitation, décrivant les champs associés aux demandes
+ properties:
+ id:
+ type: string
+ example: api_particulier
+ description: Identifiant de la définition. Celui-ci est unique.
+ name:
+ type: string
+ example: API Particulier
+ description: Nom de la définition
+ description:
+ type: string
+ example: L'API Particulier permet d'accéder à des données d'usagers
+ description: Description de la définition
+ attributes:
+ type: object
+ description: |
+ Liste des attributs associés à cette définition, sous la forme `nom: type`. Cette clé permet de déterminer les attributs dans la clé `Demande->data`. Les valeurs possibles sont uniquement `string` et `array`, les `array` sont de niveau 1 et possède uniquement des `string`
+ example:
+ intitule: string
+ description: string
+ scopes: array
+ scopes:
+ description: Scopes disponibles (vide si aucun scope)
+ type: array
+ items:
+ $ref: '#/components/schemas/ScopeDefinition'
+
+ ScopeDefinition:
+ type: object
+ description: Définition associé à un scope
+ properties:
+ name:
+ description: Nom du scope
+ example: Quotient familial CAF & MSA
+ type: string
+ value:
+ description: Valeur (technique) du scope
+ example: cnaf_quotient_familial
+ type: string
+
+ Formulaire:
+ type: object
+ description: Formulaire associé à un type d'habilitation. Un type d'habilitation peut avoir 1 ou plusieurs formulaires (en fonction du cas d'usage)
+ properties:
+ id:
+ type: string
+ example: formulaire_1
+ description: Identifiant du formulaire. Celui-ci est unique (i.e. non scopé à une définition).
+ name:
+ type: string
+ example: Formulaire pour l'éditeur UMadCorp
+ description: Nom du formulaire
+ description:
+ type: string
+ example: Ce formulaire est spécifique à l'éditeur UMadCorp et permet d'obtenir une habilitation pour le logiciel SisiLaFamille
+ description: Description du formulaire
+ definition_id:
+ type: string
+ example: api_particulier
+ description: Identidiant de la définition à laquelle le formulaire est rattaché
+ default_data:
+ type: object
+ description: Données par défaut associées aux attributs définie dans la définition. Ces données sont affectées à une demande à la création. Si à la création des données sont passé en paramètre, les données par défaut sont ignorés.
+ example:
+ intitule: "Intitulé par défaut"
+ description: "Description par défaut"
+
+ ErrorResponse:
+ type: object
+ properties:
+ errors:
+ type: array
+ items:
+ type: object
+ properties:
+ status:
+ type: string
+ description: "Le code d'état HTTP associé à l'erreur."
+ example: "422"
+ source:
+ type: object
+ properties:
+ pointer:
+ type: string
+ description: "Un pointeur JSON indiquant l'attribut source de l'erreur."
+ example: "/data/attributes/nom"
+ required:
+ - pointer
+ title:
+ type: string
+ description: "Un titre court pour l'erreur."
+ example: "Validation Error"
+ detail:
+ type: string
+ description: "Un message détaillé décrivant l'erreur."
+ example: "Le nom ne peut pas être vide."
+ required:
+ - status
+ - title
+ - detail
+
+ responses:
+ NotFoundError:
+ description: Resource non trouvée
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ UnauthorizedError:
+ description: Accès non autorisé - le jeton n'est pas valide ou absent.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ ForbiddenError:
+ description: Accès interdit - les scopes associés au jeton ne permettent pas d'effectuer cette action.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ ValidationError:
+ description: La sauvegarde ou la mise à jour a échoué.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+paths:
+ /demandes:
+ get:
+ summary: Récupérer la liste des demandes d'habilitations associé aux droits développeur de l'utilisateur
+ tags:
+ - Demandes d'habilitations
+ security:
+ - OAuth2: [read_authorization_requests]
+ parameters:
+ - name: limit
+ in: query
+ description: Nombre maximum de demandes à récupérer
+ required: false
+ schema:
+ type: integer
+ default: 10
+ minimum: 1
+ maximum: 100
+ - name: offset
+ in: query
+ description: Nombre de demandes à sauter avant de commencer à récupérer les résultats
+ required: false
+ schema:
+ type: integer
+ default: 0
+ minimum: 0
+ responses:
+ 200:
+ description: Liste des demandes récupérée avec succès
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Demande'
+ 401:
+ $ref: '#/components/responses/UnauthorizedError'
+ 403:
+ $ref: '#/components/responses/ForbiddenError'
+ 404:
+ $ref: '#/components/responses/NotFoundError'
+ post:
+ summary: Créer une nouvelle demande d'habilitation
+ tags:
+ - Non implémenté
+ deprecated: true
+ security:
+ - OAuth2: [write_authorization_requests]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreationDemande'
+ responses:
+ 201:
+ description: Demande d'habilitation créée avec succès
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/Demande'
+ 401:
+ $ref: '#/components/responses/UnauthorizedError'
+ 403:
+ $ref: '#/components/responses/ForbiddenError'
+ 422:
+ $ref: '#/components/responses/ValidationError'
+ /demandes/{id}:
+ get:
+ summary: Récupérer une demande d'habilitation
+ tags:
+ - Demandes d'habilitations
+ security:
+ - OAuth2: [read_authorization_requests]
+ parameters:
+ - name: id
+ in: path
+ description: Identifiant de la demande d'habilitation
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ description: Demande d'habilitation
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/Demande'
+ 404:
+ $ref: '#/components/responses/NotFoundError'
+ patch:
+ summary: Mettre à jour une demande d'habilitation
+ tags:
+ - Non implémenté
+ deprecated: true
+ security:
+ - OAuth2: [write_authorization_requests]
+ parameters:
+ - name: id
+ in: path
+ description: Identifiant de la demande d'habilitation
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MiseAJourDemande'
+ responses:
+ 200:
+ description: Demande d'habilitation mise à jour avec succès
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Demande'
+ 401:
+ $ref: '#/components/responses/UnauthorizedError'
+ 403:
+ $ref: '#/components/responses/ForbiddenError'
+ 422:
+ $ref: '#/components/responses/ValidationError'
+ /definitions:
+ get:
+ tags:
+ - Définitions et formulaires
+ summary: Récupérer la liste des définitions
+ security:
+ - OAuth2: [read_authorization_requests]
+ responses:
+ 200:
+ description: Liste des définitions récupérée avec succès
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Definition'
+ 401:
+ $ref: '#/components/responses/UnauthorizedError'
+ 403:
+ $ref: '#/components/responses/ForbiddenError'
+ /definitions/{id}/formulaires:
+ get:
+ summary: Récupérer la liste des formulaires pour une définition
+ tags:
+ - Définitions et formulaires
+ security:
+ - OAuth2: [read_authorization_requests]
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ description: Liste des formulaires récupérée avec succès
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Formulaire'
+ 401:
+ $ref: '#/components/responses/UnauthorizedError'
+ 403:
+ $ref: '#/components/responses/ForbiddenError'
diff --git a/config/routes.rb b/config/routes.rb
index 2b55ae196..31eabc906 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -90,8 +90,22 @@
end
end
+ use_doorkeeper scope: '/api/oauth' do
+ skip_controllers :applications, :authorized_applications
+ end
+
+ get '/api-docs/v1.yaml', to: ->(env) { [200, { 'Content-Type' => 'application/yaml', 'Content-Disposition' => 'inline;filename="datapass-v1.yaml"' }, [File.read(Rails.root.join('config/openapi/v1.yaml'))]] }, as: :open_api_v1
+ get '/developpeurs', to: redirect('/developpeurs/documentation')
+ get '/developpeurs/documentation', to: 'open_api#show'
+
namespace :api do
resources :frontal, only: :index
+
+ namespace :v1 do
+ get '/me', to: 'credentials#me'
+
+ resources :authorization_requests, path: 'demandes', only: %i[index show]
+ end
end
get '/dgfip/export', to: 'dgfip/export#show', as: :dgfip_export
diff --git a/db/migrate/20240921104426_create_doorkeeper_tables.rb b/db/migrate/20240921104426_create_doorkeeper_tables.rb
new file mode 100644
index 000000000..bd5fcff9a
--- /dev/null
+++ b/db/migrate/20240921104426_create_doorkeeper_tables.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+class CreateDoorkeeperTables < ActiveRecord::Migration[7.2]
+ def change
+ create_table :oauth_applications do |t|
+ t.string :name, null: false
+ t.string :uid, null: false
+ t.string :secret, null: false
+
+ # Remove `null: false` if you are planning to use grant flows
+ # that doesn't require redirect URI to be used during authorization
+ # like Client Credentials flow or Resource Owner Password.
+ t.text :redirect_uri, null: false
+ t.string :scopes, null: false, default: ''
+ t.boolean :confidential, null: false, default: true
+ t.timestamps null: false
+ end
+
+ add_index :oauth_applications, :uid, unique: true
+
+ create_table :oauth_access_grants do |t|
+ t.references :resource_owner, null: false
+ t.references :application, null: false
+ t.string :token, null: false
+ t.integer :expires_in, null: false
+ t.text :redirect_uri, null: false
+ t.string :scopes, null: false, default: ''
+ t.datetime :created_at, null: false
+ t.datetime :revoked_at
+ end
+
+ add_index :oauth_access_grants, :token, unique: true
+ add_foreign_key(
+ :oauth_access_grants,
+ :oauth_applications,
+ column: :application_id
+ )
+
+ create_table :oauth_access_tokens do |t|
+ t.references :resource_owner, index: true
+
+ # Remove `null: false` if you are planning to use Password
+ # Credentials Grant flow that doesn't require an application.
+ t.references :application, null: false
+
+ # If you use a custom token generator you may need to change this column
+ # from string to text, so that it accepts tokens larger than 255
+ # characters. More info on custom token generators in:
+ # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator
+ #
+ # t.text :token, null: false
+ t.string :token, null: false
+
+ t.string :refresh_token
+ t.integer :expires_in
+ t.string :scopes
+ t.datetime :created_at, null: false
+ t.datetime :revoked_at
+
+ # The authorization server MAY issue a new refresh token, in which case
+ # *the client MUST discard the old refresh token* and replace it with the
+ # new refresh token. The authorization server MAY revoke the old
+ # refresh token after issuing a new refresh token to the client.
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
+ #
+ # Doorkeeper implementation: if there is a `previous_refresh_token` column,
+ # refresh tokens will be revoked after a related access token is used.
+ # If there is no `previous_refresh_token` column, previous tokens are
+ # revoked as soon as a new access token is created.
+ #
+ # Comment out this line if you want refresh tokens to be instantly
+ # revoked after use.
+ t.string :previous_refresh_token, null: false, default: ""
+ end
+
+ add_index :oauth_access_tokens, :token, unique: true
+
+ # See https://github.com/doorkeeper-gem/doorkeeper/issues/1592
+ if ActiveRecord::Base.connection.adapter_name == "SQLServer"
+ execute <<~SQL.squish
+ CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token)
+ WHERE refresh_token IS NOT NULL
+ SQL
+ else
+ add_index :oauth_access_tokens, :refresh_token, unique: true
+ end
+
+ add_foreign_key(
+ :oauth_access_tokens,
+ :oauth_applications,
+ column: :application_id
+ )
+
+ # Uncomment below to ensure a valid reference to the resource owner's table
+ add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id
+ add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id
+ end
+end
diff --git a/db/migrate/20240921121249_add_owner_to_application.rb b/db/migrate/20240921121249_add_owner_to_application.rb
new file mode 100644
index 000000000..897ee0b60
--- /dev/null
+++ b/db/migrate/20240921121249_add_owner_to_application.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddOwnerToApplication < ActiveRecord::Migration[7.2]
+ def change
+ add_column :oauth_applications, :owner_id, :bigint, null: true
+ add_column :oauth_applications, :owner_type, :string, null: true
+ add_index :oauth_applications, [:owner_id, :owner_type]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 712b2a765..c981a7faf 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -282,6 +282,51 @@
t.index ["from_id"], name: "index_messages_on_from_id"
end
+ create_table "oauth_access_grants", force: :cascade do |t|
+ t.bigint "resource_owner_id", null: false
+ t.bigint "application_id", null: false
+ t.string "token", null: false
+ t.integer "expires_in", null: false
+ t.text "redirect_uri", null: false
+ t.string "scopes", default: "", null: false
+ t.datetime "created_at", null: false
+ t.datetime "revoked_at"
+ t.index ["application_id"], name: "index_oauth_access_grants_on_application_id"
+ t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id"
+ t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true
+ end
+
+ create_table "oauth_access_tokens", force: :cascade do |t|
+ t.bigint "resource_owner_id"
+ t.bigint "application_id", null: false
+ t.string "token", null: false
+ t.string "refresh_token"
+ t.integer "expires_in"
+ t.string "scopes"
+ t.datetime "created_at", null: false
+ t.datetime "revoked_at"
+ t.string "previous_refresh_token", default: "", null: false
+ t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id"
+ t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true
+ t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id"
+ t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true
+ end
+
+ create_table "oauth_applications", force: :cascade do |t|
+ t.string "name", null: false
+ t.string "uid", null: false
+ t.string "secret", null: false
+ t.text "redirect_uri", null: false
+ t.string "scopes", default: "", null: false
+ t.boolean "confidential", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "owner_id"
+ t.string "owner_type"
+ t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type"
+ t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
+ end
+
create_table "organizations", force: :cascade do |t|
t.string "siret", null: false
t.jsonb "mon_compte_pro_payload", default: {}, null: false
@@ -356,5 +401,9 @@
add_foreign_key "malware_scans", "active_storage_attachments", column: "attachment_id"
add_foreign_key "messages", "authorization_requests"
add_foreign_key "messages", "users", column: "from_id"
+ add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
+ add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
+ add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
+ add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id"
add_foreign_key "revocation_of_authorizations", "authorization_requests"
end
diff --git a/features/developpeurs/documentation.feature b/features/developpeurs/documentation.feature
new file mode 100644
index 000000000..4cf8a8bee
--- /dev/null
+++ b/features/developpeurs/documentation.feature
@@ -0,0 +1,9 @@
+# language: fr
+
+Fonctionnalité: Développeurs: consultation de la documentation
+ @javascript
+
+ Scénario: Je peux voir la documentation OpenAPI de manière jolie
+ Quand je me rends sur le chemin "/developpeurs/documentation"
+ Alors la page contient "API DataPass"
+
diff --git a/features/step_definitions/web_steps.rb b/features/step_definitions/web_steps.rb
index 9b6bfcde3..0c171f67c 100644
--- a/features/step_definitions/web_steps.rb
+++ b/features/step_definitions/web_steps.rb
@@ -2,6 +2,10 @@
visit root_path
end
+Quand('je me rends sur le chemin {string}') do |string|
+ visit string
+end
+
Quand('print the page') do
log page.body
end
diff --git a/spec/factories/oauth.rb b/spec/factories/oauth.rb
new file mode 100644
index 000000000..fb40ce071
--- /dev/null
+++ b/spec/factories/oauth.rb
@@ -0,0 +1,13 @@
+FactoryBot.define do
+ factory :oauth_application, class: 'Doorkeeper::Application' do
+ name { 'MyApp' }
+ redirect_uri { 'https://app.com/callback' }
+ owner factory: :user
+ end
+
+ factory :access_token, class: 'Doorkeeper::AccessToken' do
+ application factory: :oauth_application
+ expires_in { 2.hours }
+ scopes { 'public' }
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index b4db41273..3be37c93f 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -42,5 +42,19 @@
end
end
end
+
+ trait :developer do
+ transient do
+ authorization_request_types do
+ %w[hubee_cert_dc api_entreprise]
+ end
+ end
+
+ after(:build) do |user, evaluator|
+ evaluator.authorization_request_types.each do |authorization_request_type|
+ user.roles << "#{authorization_request_type}:developer"
+ end
+ end
+ end
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 12d1ca6aa..5249e40e5 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -22,12 +22,13 @@
let(:authorization_request_type) { 'api_entreprise' }
+ let!(:valid_developer) { create(:user, :developer, authorization_request_types: %i[api_entreprise]) }
let!(:valid_instructor) { create(:user, :instructor, authorization_request_types: %i[api_entreprise]) }
let!(:valid_instructor_with_multiple_authorization_type) { create(:user, :instructor, authorization_request_types: %i[api_entreprise api_particulier]) }
let!(:invalid_instructor) { create(:user, :instructor, authorization_request_types: %i[api_particulier]) }
let!(:valid_reporter) { create(:user, :reporter, authorization_request_types: %i[api_entreprise]) }
- it { is_expected.to contain_exactly(valid_reporter, valid_instructor, valid_instructor_with_multiple_authorization_type) }
+ it { is_expected.to contain_exactly(valid_reporter, valid_developer, valid_instructor, valid_instructor_with_multiple_authorization_type) }
end
describe '#reporter?' do
diff --git a/spec/requests/api/open_api_spec.rb b/spec/requests/api/open_api_spec.rb
new file mode 100644
index 000000000..7b19a43a3
--- /dev/null
+++ b/spec/requests/api/open_api_spec.rb
@@ -0,0 +1,12 @@
+RSpec.describe 'OpenAPI files', type: :request do
+ it 'works and render a valid OpenAPI file' do
+ get '/api-docs/v1.yaml'
+
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type).to eq('application/yaml')
+ expect(YAML.safe_load(response.body)).to be_a(Hash)
+
+ document = Openapi3Parser.load(response.body)
+ expect(document).to be_valid, "OpenAPI file is invalid: #{document.errors}"
+ end
+end
diff --git a/spec/requests/api/v1/authorization_requests_controller_spec.rb b/spec/requests/api/v1/authorization_requests_controller_spec.rb
new file mode 100644
index 000000000..656bb8f77
--- /dev/null
+++ b/spec/requests/api/v1/authorization_requests_controller_spec.rb
@@ -0,0 +1,53 @@
+RSpec.describe 'API: Authorization requests', type: :request do
+ let(:user) { create(:user, :developer, authorization_request_types: %w[api_entreprise]) }
+ let(:application) { create(:oauth_application, owner: user) }
+ let(:access_token) { create(:access_token, application:, resource_owner_id: user.id) }
+
+ describe 'index' do
+ subject(:get_index) do
+ get '/api/v1/demandes', headers: { 'Authorization' => "Bearer #{access_token.token}" }
+ end
+
+ context 'when there is at least one authorization request associated to one of the user developer role' do
+ let!(:valid_authorization_request) { create(:authorization_request, :api_entreprise) }
+ let!(:invalid_authorization_request) { create(:authorization_request, :api_particulier) }
+
+ it 'reponds OK with data' do
+ get_index
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body.count).to eq(1)
+ expect(response.parsed_body[0]['id']).to eq(valid_authorization_request.id)
+ end
+ end
+ end
+
+ describe 'show' do
+ subject(:get_show) do
+ get "/api/v1/demandes/#{id}", headers: { 'Authorization' => "Bearer #{access_token.token}" }
+ end
+
+ let(:id) { authorization_request.id }
+
+ context 'with valid authorization request' do
+ let(:authorization_request) { create(:authorization_request, :api_entreprise) }
+
+ it 'responds OK with data' do
+ get_show
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body['id']).to eq(authorization_request.id)
+ end
+ end
+
+ context 'with invalid authorization request' do
+ let(:authorization_request) { create(:authorization_request, :api_particulier) }
+
+ it 'responds 404' do
+ get_show
+
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/v1/credentials_controller_spec.rb b/spec/requests/api/v1/credentials_controller_spec.rb
new file mode 100644
index 000000000..c530da5a7
--- /dev/null
+++ b/spec/requests/api/v1/credentials_controller_spec.rb
@@ -0,0 +1,27 @@
+RSpec.describe 'API: Credentials', type: :request do
+ context 'when unauthorized' do
+ it 'returns unauthorized' do
+ get '/api/v1/me'
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.parsed_body['errors']).to include(
+ hash_including(
+ status: '401'
+ )
+ )
+ end
+ end
+
+ context 'when authorized' do
+ let(:user) { create(:user) }
+ let(:application) { create(:oauth_application, owner: user) }
+ let(:access_token) { create(:access_token, application:, resource_owner_id: user.id) }
+
+ it 'returns the current user' do
+ get '/api/v1/me', params: {}, headers: { 'Authorization' => "Bearer #{access_token.token}" }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body).to eq(user.attributes.slice('id', 'email', 'family_name', 'given_name', 'job_title'))
+ end
+ end
+end