Commit c4982890 authored by Markus Koller's avatar Markus Koller Committed by Alexis Reigel

Implement OpenID Connect identity provider

parent fb4a4866
......@@ -20,6 +20,7 @@ gem 'rugged', '~> 0.24.0'
# Authentication libraries
gem 'devise', '~> 4.2'
gem 'doorkeeper', '~> 4.2.0'
gem 'doorkeeper-openid_connect', '~> 1.1.0'
gem 'omniauth', '~> 1.4.2'
gem 'omniauth-auth0', '~> 1.4.1'
gem 'omniauth-azure-oauth2', '~> 0.0.6'
......
......@@ -78,6 +78,7 @@ GEM
better_errors (1.0.1)
coderay (>= 1.0.0)
erubis (>= 2.6.6)
bindata (2.3.5)
binding_of_caller (0.7.2)
debug_inspector (>= 0.0.1)
bootstrap-sass (3.3.6)
......@@ -167,6 +168,9 @@ GEM
unf (>= 0.0.5, < 1.0.0)
doorkeeper (4.2.0)
railties (>= 4.2)
doorkeeper-openid_connect (1.1.2)
doorkeeper (~> 4.0)
json-jwt (~> 1.6)
dropzonejs-rails (0.7.2)
rails (> 3.1)
email_reply_trimmer (0.1.6)
......@@ -376,6 +380,12 @@ GEM
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (1.8.6)
json-jwt (1.7.1)
activesupport
bindata
multi_json (>= 1.3)
securecompare
url_safe_base64
json-schema (2.6.2)
addressable (~> 2.3.8)
jwt (1.5.6)
......@@ -684,6 +694,7 @@ GEM
scss_lint (0.47.1)
rake (>= 0.9, < 11)
sass (~> 3.4.15)
securecompare (1.0.0)
seed-fu (2.3.6)
activerecord (>= 3.1)
activesupport (>= 3.1)
......@@ -789,6 +800,7 @@ GEM
get_process_mem (~> 0)
unicorn (>= 4, < 6)
uniform_notifier (1.10.0)
url_safe_base64 (0.2.2)
validates_hostname (1.0.6)
activerecord (>= 3.0)
activesupport (>= 3.0)
......@@ -866,6 +878,7 @@ DEPENDENCIES
devise-two-factor (~> 3.0.0)
diffy (~> 3.1.0)
doorkeeper (~> 4.2.0)
doorkeeper-openid_connect (~> 1.1.0)
dropzonejs-rails (~> 0.7.1)
email_reply_trimmer (~> 0.1)
email_spec (~> 1.6.0)
......
class OauthAccessGrant < Doorkeeper::AccessGrant
belongs_to :resource_owner, class_name: 'User'
belongs_to :application, class_name: 'Doorkeeper::Application'
end
class OauthAccessToken < ActiveRecord::Base
class OauthAccessToken < Doorkeeper::AccessToken
belongs_to :resource_owner, class_name: 'User'
belongs_to :application, class_name: 'Doorkeeper::Application'
end
......@@ -27,6 +27,7 @@
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= hidden_field_tag :nonce, @pre_auth.nonce
= submit_tag "Authorize", class: "btn btn-success wide pull-left"
= form_tag oauth_authorization_path, method: :delete do
= hidden_field_tag :client_id, @pre_auth.client.uid
......@@ -34,4 +35,5 @@
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= hidden_field_tag :nonce, @pre_auth.nonce
= submit_tag "Deny", class: "btn btn-danger prepend-left-10"
---
title: Implement OpenID Connect identity provider
merge_request: 8018
author: Markus Koller
......@@ -8,7 +8,12 @@ Doorkeeper.configure do
# Put your resource owner authentication logic here.
# Ensure user is redirected to redirect_uri after login
session[:user_return_to] = request.fullpath
current_user || redirect_to(new_user_session_url)
if current_user
current_user
else
redirect_to(new_user_session_url)
nil
end
end
resource_owner_from_credentials do |routes|
......
Doorkeeper::OpenidConnect.configure do
issuer Gitlab.config.gitlab.url
jws_private_key Rails.application.secrets.jws_private_key
resource_owner_from_access_token do |access_token|
User.active.find_by(id: access_token.resource_owner_id)
end
auth_time_from_resource_owner do |user|
user.current_sign_in_at
end
reauthenticate_resource_owner do |user, return_to|
store_location_for user, return_to
sign_out user
redirect_to new_user_session_url
end
subject do |user|
# hash the user's ID with the Rails secret_key_base to avoid revealing it
Digest::SHA256.hexdigest "#{user.id}-#{Rails.application.secrets.secret_key_base}"
end
claims do
with_options scope: :openid do |o|
o.claim(:name) { |user| user.name }
o.claim(:nickname) { |user| user.username }
o.claim(:email) { |user| user.public_email }
o.claim(:email_verified) { |user| true if user.public_email? }
o.claim(:website) { |user| user.full_website_url if user.website_url? }
o.claim(:profile) { |user| Rails.application.routes.url_helpers.user_url user }
o.claim(:picture) { |user| user.avatar_url }
end
end
end
......@@ -24,7 +24,8 @@ def create_tokens
defaults = {
secret_key_base: file_secret_key || generate_new_secure_token,
otp_key_base: env_secret_key || file_secret_key || generate_new_secure_token,
db_key_base: generate_new_secure_token
db_key_base: generate_new_secure_token,
jws_private_key: generate_new_rsa_private_key
}
missing_secrets = set_missing_keys(defaults)
......@@ -41,6 +42,10 @@ def generate_new_secure_token
SecureRandom.hex(64)
end
def generate_new_rsa_private_key
OpenSSL::PKey::RSA.new(2048).to_pem
end
def warn_missing_secret(secret)
warn "Missing Rails.application.secrets.#{secret} for #{Rails.env} environment. The secret will be generated and stored in config/secrets.yml."
end
......
......@@ -60,6 +60,7 @@ en:
scopes:
api: Access your API
read_user: Read user information
openid: Authenticate using OpenID Connect
flash:
applications:
......
......@@ -22,6 +22,8 @@ Rails.application.routes.draw do
authorizations: 'oauth/authorizations'
end
use_doorkeeper_openid_connect
# Autocomplete
get '/autocomplete/users' => 'autocomplete#users'
get '/autocomplete/users/:id' => 'autocomplete#user'
......
class CreateDoorkeeperOpenidConnectTables < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
create_table :oauth_openid_requests do |t|
t.integer :access_grant_id, null: false
t.string :nonce, null: false
end
if Gitlab::Database.postgresql?
# add foreign key without validation to avoid downtime on PostgreSQL,
# also see db/post_migrate/20170209140523_validate_foreign_keys_on_oauth_openid_requests.rb
execute %q{
ALTER TABLE "oauth_openid_requests"
ADD CONSTRAINT "fk_oauth_openid_requests_oauth_access_grants_access_grant_id"
FOREIGN KEY ("access_grant_id")
REFERENCES "oauth_access_grants" ("id")
NOT VALID;
}
else
execute %q{
ALTER TABLE oauth_openid_requests
ADD CONSTRAINT fk_oauth_openid_requests_oauth_access_grants_access_grant_id
FOREIGN KEY (access_grant_id)
REFERENCES oauth_access_grants (id);
}
end
end
def down
drop_table :oauth_openid_requests
end
end
class ValidateForeignKeysOnOauthOpenidRequests < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
if Gitlab::Database.postgresql?
execute %q{
ALTER TABLE "oauth_openid_requests"
VALIDATE CONSTRAINT "fk_oauth_openid_requests_oauth_access_grants_access_grant_id";
}
end
end
def down
# noop
end
end
......@@ -878,6 +878,11 @@ ActiveRecord::Schema.define(version: 20170306170512) do
add_index "oauth_applications", ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type", using: :btree
add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree
create_table "oauth_openid_requests", force: :cascade do |t|
t.integer "access_grant_id", null: false
t.string "nonce", null: false
end
create_table "pages_domains", force: :cascade do |t|
t.integer "project_id"
t.text "certificate"
......@@ -1374,6 +1379,7 @@ ActiveRecord::Schema.define(version: 20170306170512) do
add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade
add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", name: "fk_oauth_openid_requests_oauth_access_grants_access_grant_id"
add_foreign_key "personal_access_tokens", "users"
add_foreign_key "project_authorizations", "projects", on_delete: :cascade
add_foreign_key "project_authorizations", "users", on_delete: :cascade
......
......@@ -12,6 +12,7 @@ See the documentation below for details on how to configure these services.
- [SAML](saml.md) Configure GitLab as a SAML 2.0 Service Provider
- [CAS](cas.md) Configure GitLab to sign in using CAS
- [OAuth2 provider](oauth_provider.md) OAuth2 application creation
- [OpenID Connect](openid_connect_provider.md) Use GitLab as an identity provider
- [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages
- [reCAPTCHA](recaptcha.md) Configure GitLab to use Google reCAPTCHA for new users
- [Akismet](akismet.md) Configure Akismet to stop spam
......
# GitLab as OpenID Connect identity provider
This document is about using GitLab as an OpenID Connect identity provider
to sign in to other services.
## Introduction to OpenID Connect
[OpenID Connect] \(OIC) is a simple identity layer on top of the
OAuth 2.0 protocol. It allows clients to verify the identity of the end-user
based on the authentication performed by GitLab, as well as to obtain
basic profile information about the end-user in an interoperable and
REST-like manner. OIC performs many of the same tasks as OpenID 2.0,
but does so in a way that is API-friendly, and usable by native and
mobile applications.
On the client side, you can use [omniauth-openid-connect] for Rails
applications, or any of the other available [client implementations].
GitLab's implementation uses the [doorkeeper-openid_connect] gem, refer
to its README for more details about which parts of the specifications
are supported.
## Enabling OpenID Connect for OAuth applications
Refer to the [OAuth guide] for basic information on how to set up OAuth
applications in GitLab. To enable OIC for an application, all you have to do
is select the `openid` scope in the application settings.
Currently the following user information is shared with clients:
| Claim | Type | Description |
|:-----------------|:----------|:------------|
| `sub` | `string` | An opaque token that uniquely identifies the user
| `auth_time` | `integer` | The timestamp for the user's last authentication
| `name` | `string` | The user's full name
| `nickname` | `string` | The user's GitLab username
| `email` | `string` | The user's public email address
| `email_verified` | `boolean` | Whether the user's public email address was verified
| `website` | `string` | URL for the user's website
| `profile` | `string` | URL for the user's GitLab profile
| `picture` | `string` | URL for the user's GitLab avatar
[OpenID Connect]: http://openid.net/connect/ "OpenID Connect website"
[doorkeeper-openid_connect]: https://github.com/doorkeeper-gem/doorkeeper-openid_connect "Doorkeeper::OpenidConnect website"
[OAuth guide]: oauth_provider.md "GitLab as OAuth2 authentication service provider"
[omniauth-openid-connect]: https://github.com/jjbohn/omniauth-openid-connect/ "OmniAuth::OpenIDConnect website"
[client implementations]: http://openid.net/developers/libraries#connect "List of available client implementations"
......@@ -2,7 +2,7 @@ module Gitlab
module Auth
MissingPersonalTokenError = Class.new(StandardError)
SCOPES = [:api, :read_user].freeze
SCOPES = [:api, :read_user, :openid, :profile, :email].freeze
DEFAULT_SCOPES = [:api].freeze
OPTIONAL_SCOPES = SCOPES - DEFAULT_SCOPES
......
FactoryGirl.define do
factory :oauth_access_grant do
resource_owner_id { create(:user).id }
application
token { Doorkeeper::OAuth::Helpers::UniqueToken.generate }
expires_in 2.hours
redirect_uri { application.redirect_uri }
scopes { application.scopes }
end
end
......@@ -2,6 +2,7 @@ FactoryGirl.define do
factory :oauth_access_token do
resource_owner
application
token '123456'
token { Doorkeeper::OAuth::Helpers::UniqueToken.generate }
scopes { application.scopes }
end
end
FactoryGirl.define do
factory :oauth_application, class: 'Doorkeeper::Application', aliases: [:application] do
name { FFaker::Name.name }
uid { FFaker::Name.name }
uid { Doorkeeper::OAuth::Helpers::UniqueToken.generate }
redirect_uri { FFaker::Internet.uri('http') }
owner
owner_type 'User'
......
......@@ -6,6 +6,9 @@ describe 'create_tokens', lib: true do
let(:secrets) { ActiveSupport::OrderedOptions.new }
HEX_KEY = /\h{128}/
RSA_KEY = /\A-----BEGIN RSA PRIVATE KEY-----\n.+\n-----END RSA PRIVATE KEY-----\n\Z/m
before do
allow(File).to receive(:write)
allow(File).to receive(:delete)
......@@ -15,7 +18,7 @@ describe 'create_tokens', lib: true do
allow(self).to receive(:exit)
end
context 'setting secret_key_base and otp_key_base' do
context 'setting secret keys' do
context 'when none of the secrets exist' do
before do
stub_env('SECRET_KEY_BASE', nil)
......@@ -24,19 +27,29 @@ describe 'create_tokens', lib: true do
allow(self).to receive(:warn_missing_secret)
end
it 'generates different secrets for secret_key_base, otp_key_base, and db_key_base' do
it 'generates different hashes for secret_key_base, otp_key_base, and db_key_base' do
create_tokens
keys = secrets.values_at(:secret_key_base, :otp_key_base, :db_key_base)
expect(keys.uniq).to eq(keys)
expect(keys.map(&:length)).to all(eq(128))
expect(keys).to all(match(HEX_KEY))
end
it 'generates an RSA key for jws_private_key' do
create_tokens
keys = secrets.values_at(:jws_private_key)
expect(keys.uniq).to eq(keys)
expect(keys).to all(match(RSA_KEY))
end
it 'warns about the secrets to add to secrets.yml' do
expect(self).to receive(:warn_missing_secret).with('secret_key_base')
expect(self).to receive(:warn_missing_secret).with('otp_key_base')
expect(self).to receive(:warn_missing_secret).with('db_key_base')
expect(self).to receive(:warn_missing_secret).with('jws_private_key')
create_tokens
end
......@@ -48,6 +61,7 @@ describe 'create_tokens', lib: true do
expect(new_secrets['secret_key_base']).to eq(secrets.secret_key_base)
expect(new_secrets['otp_key_base']).to eq(secrets.otp_key_base)
expect(new_secrets['db_key_base']).to eq(secrets.db_key_base)
expect(new_secrets['jws_private_key']).to eq(secrets.jws_private_key)
end
create_tokens
......@@ -63,6 +77,7 @@ describe 'create_tokens', lib: true do
context 'when the other secrets all exist' do
before do
secrets.db_key_base = 'db_key_base'
secrets.jws_private_key = 'jws_private_key'
allow(File).to receive(:exist?).with('.secret').and_return(true)
allow(File).to receive(:read).with('.secret').and_return('file_key')
......@@ -73,6 +88,7 @@ describe 'create_tokens', lib: true do
stub_env('SECRET_KEY_BASE', 'env_key')
secrets.secret_key_base = 'secret_key_base'
secrets.otp_key_base = 'otp_key_base'
secrets.jws_private_key = 'jws_private_key'
end
it 'does not issue a warning' do
......@@ -98,6 +114,7 @@ describe 'create_tokens', lib: true do
before do
secrets.secret_key_base = 'secret_key_base'
secrets.otp_key_base = 'otp_key_base'
secrets.jws_private_key = 'jws_private_key'
end
it 'does not write any files' do
......@@ -112,6 +129,7 @@ describe 'create_tokens', lib: true do
expect(secrets.secret_key_base).to eq('secret_key_base')
expect(secrets.otp_key_base).to eq('otp_key_base')
expect(secrets.db_key_base).to eq('db_key_base')
expect(secrets.jws_private_key).to eq('jws_private_key')
end
it 'deletes the .secret file' do
......@@ -135,6 +153,7 @@ describe 'create_tokens', lib: true do
expect(new_secrets['secret_key_base']).to eq('file_key')
expect(new_secrets['otp_key_base']).to eq('file_key')
expect(new_secrets['db_key_base']).to eq('db_key_base')
expect(new_secrets['jws_private_key']).to eq('jws_private_key')
end
create_tokens
......
require 'spec_helper'
describe 'OpenID Connect requests' do
include ApiHelpers
let(:user) { create :user }
let(:access_grant) { create :oauth_access_grant, application: application, resource_owner_id: user.id }
let(:access_token) { create :oauth_access_token, application: application, resource_owner_id: user.id }
def request_access_token
login_as user
post '/oauth/token',
grant_type: 'authorization_code',
code: access_grant.token,
redirect_uri: application.redirect_uri,
client_id: application.uid,
client_secret: application.secret
end
def request_user_info
get '/oauth/userinfo', nil, 'Authorization' => "Bearer #{access_token.token}"
end
def hashed_subject
Digest::SHA256.hexdigest("#{user.id}-#{Rails.application.secrets.secret_key_base}")
end
context 'Application without OpenID scope' do
let(:application) { create :oauth_application, scopes: 'api' }
it 'token response does not include an ID token' do
request_access_token
expect(json_response).to include 'access_token'
expect(json_response).not_to include 'id_token'
end
it 'userinfo response is unauthorized' do
request_user_info
expect(response).to have_http_status 403
expect(response.body).to be_blank
end
end
context 'Application with OpenID scope' do
let(:application) { create :oauth_application, scopes: 'openid' }
it 'token response includes an ID token' do
request_access_token
expect(json_response).to include 'id_token'
end
context 'UserInfo payload' do
let(:user) do
create(
:user,
name: 'Alice',
username: 'alice',
emails: [private_email, public_email],
email: private_email.email,
public_email: public_email.email,
website_url: 'https://example.com',
avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png"),
)
end
let(:public_email) { build :email, email: 'public@example.com' }
let(:private_email) { build :email, email: 'private@example.com' }
it 'includes all user information' do
request_user_info
expect(json_response).to eq({
'sub' => hashed_subject,
'name' => 'Alice',
'nickname' => 'alice',
'email' => 'public@example.com',
'email_verified' => true,
'website' => 'https://example.com',
'profile' => 'http://localhost/alice',
'picture' => "http://localhost/uploads/user/avatar/#{user.id}/dk.png",
})
end
end
context 'ID token payload' do
before do
request_access_token
@payload = JSON::JWT.decode(json_response['id_token'], :skip_verification)
end
it 'includes the Gitlab root URL' do
expect(@payload['iss']).to eq Gitlab.config.gitlab.url
end
it 'includes the hashed user ID' do
expect(@payload['sub']).to eq hashed_subject
end
it 'includes the time of the last authentication' do
expect(@payload['auth_time']).to eq user.current_sign_in_at.to_i
end
it 'does not include any unknown properties' do
expect(@payload.keys).to eq %w[iss sub aud exp iat auth_time]
end
end
context 'when user is blocked' do
it 'returns authentication error' do
access_grant
user.block
expect do
request_access_token
end.to throw_symbol :warden
end
end
context 'when user is ldap_blocked' do
it 'returns authentication error' do
access_grant
user.ldap_block
expect do
request_access_token
end.to throw_symbol :warden
end
end
end
end
require 'spec_helper'
# oauth_discovery_keys GET /oauth/discovery/keys(.:format) doorkeeper/openid_connect/discovery#keys
# oauth_discovery_provider GET /.well-known/openid-configuration(.:format) doorkeeper/openid_connect/discovery#provider
# oauth_discovery_webfinger GET /.well-known/webfinger(.:format) doorkeeper/openid_connect/discovery#webfinger
describe Doorkeeper::OpenidConnect::DiscoveryController, 'routing' do
it "to #provider" do
expect(get('/.well-known/openid-configuration')).to route_to('doorkeeper/openid_connect/discovery#provider')
end
it "to #webfinger" do
expect(get('/.well-known/webfinger')).to route_to('doorkeeper/openid_connect/discovery#webfinger')
end
it "to #keys" do
expect(get('/oauth/discovery/keys')).to route_to('doorkeeper/openid_connect/discovery#keys')
end
end
# oauth_userinfo GET /oauth/userinfo(.:format) doorkeeper/openid_connect/userinfo#show
# POST /oauth/userinfo(.:format) doorkeeper/openid_connect/userinfo#show
describe Doorkeeper::OpenidConnect::UserinfoController, 'routing' do
it "to #show" do
expect(get('/oauth/userinfo')).to route_to('doorkeeper/openid_connect/userinfo#show')
end
it "to #show" do
expect(post('/oauth/userinfo')).to route_to('doorkeeper/openid_connect/userinfo#show')
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment