OmniAuth globaliD

omniauth-globalid is a rack middleware for authenticating with globaliD. It supports OAuth2 authentication and openID Connect. This gem contains the GlobaliD strategy for OmniAuth, and includes functionality for accessing PII that authenticated users share with you.

Installation

Install this gem by adding it to your gemfile,

gem "omniauth-globalid"

Then bundle install

If you're adding this to a Rails app using devise with OmniAuth, add this to your config/initializers/devise.rb:

config.omniauth :globalid, ENV["GLOBALID_CLIENT_ID"], ENV["GLOBALID_CLIENT_SECRET"],

Otherwise, you'll probably want to add this to the middleware of the Rails app in config/initializers/omniauth.rb:

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :globalid, ENV["GLOBALID_CLIENT_ID"], ENV["GLOBALID_CLIENT_SECRET"]
end

Options you can pass in the initialization (none are required):

Parameter Description
acrc_id Verification Requirements, e.g. a requirement that the user has a valid government id
scope Must be openid if passing an acrc_id that specifies PII sharing
private_key Private key given to globaliD. Required for PII sharing
private_key_pass Password for private_key specified
decrypt_pii_on_login Decrypt PII on login, passing it through the authentication hash

Here is what a configuration for a setup that uses PII sharing looks like:

provider :globalid,
         ENV["GLOBALID_CLIENT_ID"],
         ENV["GLOBALID_CLIENT_SECRET"],
         acrc_id: ENV["ACRC_ID"],
         scope: "openid",
         private_key: ENV["GLOBALID_PRIVATE_KEY"],
         private_key_pass: ENV["GLOBALID_PRIVATE_KEY_PASS"],
         decrypt_pii_on_login: true

If you're curious about what those options mean, or how to use them, read globaliD's documentation or the walkthroughs in this readme.

Local development

Run the tests with rake

Use bundle exec guard to watch the files for changes and rerun tests

globaliD Authentication Walkthroughs

You can just install this gem which manages all this for you 🙂 - but if you want to understand how all this works, there are walkthroughs for these topics:

Setup

To be able to run these walkthroughs you first have to install the necessary gems:

gem install "dotenv"
gem install "jwt"
gem install "faraday"

And set up a .env file that has values for these keys: GLOBALID_CLIENT_ID, GLOBALID_CLIENT_SECRET, ACRC_ID, REDIRECT_URL

If you're going to execute the walkthrough commands in IRB, enter irb and run these commands:

require "dotenv/load" # Load the .env file
require "jwt" # Load the gem for decoding JWTs
require "faraday" # http request library
token_url = "https://api.globalid.net/v1/auth/token" # token_url is used multiple times, so store in a variable

OAuth2 Authorization Code Request Flow

The server side authorization flow for "Sign in with GlobaliD"

1. Create a URL that renders the globaliD sign in page

This URL includes these parameters

Parameter Required? Description
client_id ✔ Defines which app is making this authentication request. Setup in globaliD's developer panel
scope ✔ Defines what you are asking permission to do for the user. In the basic setup, this is public
redirect_uri ✔ Where to send user after authentication, must match the app defined by the client_id
response_type ✔ Needs to be code, because we're doing the authorization code flow
state ✔ Security parameter to prevent request forgery
acrc_id Verification Requirements, e.g. a requirement that the user has a valid government id

This will create the URL in IRB:

# These are the parameters for the globaliD authorization URL:
authorization_params = { client_id: ENV["GLOBALID_CLIENT_ID"], acrc_id: ENV["ACRC_ID"], redirect_uri: ENV["REDIRECT_URL"], grant_type: "authorization_code", nonce: "something-random", response_type: "code", scope: "public" }
# Which you use to compose the globaliD authorization URL -
authorization_url = "https://auth.global.id?" + URI.encode_www_form(authorization_params)

2. Send user to the generated globaliD url

After the user authenticates, they are redirected to the redirect_url with these parameters:

Parameter Description
grant_type The type of OAuth flow - we're doing the authorization_code
code The authorization code, which we will use to get an access token
state Security parameter to prevent request forgery

... If you're following along in IRB, you can open the authorization url in your browser with:

system "open '#{authorization_url}'"

3. Receive the request that comes to the redirect_url

The URL will look like:

https://global.id/fake_redirect_auth/?grant_type=authorization_code&code=7b35a90b8f904aae9db676660e33784e

The code parameter's value from the above URL is 7b35a90b8f904aae9db676660e33784e. Assign this to code in IRB:

code = "7b35a90b8f904aae9db676660e33784e" # use YOUR code, not the sample ;)

If you have a functioning Omniauth installation this will happen in your controller

4. Make a request to globaliD's token URL to get the access_token

authorization_code_token_params = { client_id: ENV["GLOBALID_CLIENT_ID"], client_secret: ENV["GLOBALID_CLIENT_SECRET"], grant_type: "authorization_code", code: code, redirect_uri: ENV["REDIRECT_URL"] }
token_response = Faraday.new(url: token_url).post do |req|
  req.headers["Content-Type"] = "application/x-www-form-urlencoded"
  req.body = URI.encode_www_form(authorization_code_token_params)
end

5. Parse the JSON from token_response above

The token_response.body for the above request will be JSON that looks something like this:

{
  access_token: "eyJhb....", // This will be a big string, truncated here for legibility
  token_type: "bearer",
  expires_in: 7200,
  refresh_token: "4806a8863c62a6509046638d80c37d16",
  id_token: null
}

You'll need to store the access_token, refresh_token and scope to be able to make authenticated requests to globaliD's API.

Storing the expiration (current time + expires_in seconds) is a good idea - it's when you'll need to use the refresh token to get a new access_token.

With this access_token you can make authenticated requests to globaliD's APIs

Notes


OpenID Connect Flow

OpenID Connect is just an expansion of the OAuth2 request flow (documented above)

The differences are:

  • You must use the scope openid and include an acrc_id parameter when creating the URL for the user to authenticate (step 1)
  • The access token response is different and requires additional parsing (step 5)

So, for the OpenID Connect flow follow steps 1 through 4 for the OAuth2 Authorization Code request flow (making sure to use the openid scope and include a acrc_id), and replace step 5 with this:

5. Parse the JSON from the access token response and decode the JSON Web Token

The token_response from the OpenID Connect response will look something like this:

{
  access_token: "eyJhbGciOiJSUzI1", // truncated for legibility
  token_type: "bearer",
  expires_in: 7200,
  refresh_token: "0ec3c111cb18f4a48db2d8246b8cb5eb",
  id_token: "eyJhbGciOiJSUzI1NiIsIn" // starts same as access token but is longer, truncated for legibility
}

The id_token parameter from this response is a JSON Web Token (JWT).

We only need the id_token value from this response, which we get and decode like this:

id_token = JSON.parse(token_response.body)["id_token"]
# NOTE: The JWT signature from globaliD's API is broken right now because of an issue with the location of the public key
# So for now, we skip verifying the signature, and parse the JWT with this:
decoded_token = JWT.decode(id_token, nil, false).first

The decoded JWT looks like this:

{
  "sub": "ef141f5d-2a9f-429d-999f-8bbec78a733a", // the globaliD UUID for the user who authenticated
  "iss": "https://globalid.net",
  "nonce": "af78x76zv87xv78v",
  "iat": "1570659177025",
  "exp": "1570745577026",
  "idp.globalid.net/claims/null": {},
  "idp.globalid.net/claims/dd24263d-079b-4779-9776-167fe6e03ab8": {
    "bf4cd542-216f-4377-bc46-7601eca09048": [
      "WQexnTFKt1E...." // Base64 encoded, encrypted claims token
    ]
  }
}

You can use the access_token from this response to make authenticated requests to globaliD's APIs.

The claims key-value pairs from this JWT include the data necessary to access PII from the Vault.

Notes

  • Using the visual decoder and debugger at jwt.io can be helpful for understanding how globaliD's OpenID Connect response works (and understanding JWTs in general)

Refresh access tokens Flow

This is how to refresh an access_token once its expiration passes

You can determine if you need to refresh the access token if the expiration time has passed, or if you get a 401 status response with a JSON body that includes the key-value pair: "message": "The bearer token has expired".

1. Make a POST request to the token url with the refresh_token, client_id and client_secret

refresh_token = "70165f183e7efee2b298302bcaea5276" # Replace with the actual code you have
refresh_params = { client_id: ENV["GLOBALID_CLIENT_ID"], redirect_uri: ENV["REDIRECT_URL"], grant_type: "refresh_token", refresh_token: refresh_token }
response = Faraday.new(url: token_url).post do |req|
  req.headers["Content-Type"] = "application/x-www-form-urlencoded"
  req.body = URI.encode_www_form(refresh_params)
end

2.Parse the JSON in the response.body, the access token is the access_token value

You can use this access token until it expires - at which point you'll need to request a new access token, using the refresh token you were given in the original request (just follow the refresh access token flow again).


Access PII from the Vault

To be able to make an ACRC request with PII You also must have given globaliD an encryption key.

The key must be in the .pem format, (you'll be required to enter a password) - generate it with:

openssl genrsa -des3 -out key_for_globaliD.key 4096
openssl rsa -in "key_for_globaliD.key" -pubout > "public_key_for_globaliD.pub"

Once you have those files:

  • Send the public key (public_key_for_globaliD.pub generated from the above snippet) to [email protected]
  • Add the private key (key_for_globaliD.key) to your .env file under as GLOBALID_PRIVATE_KEY (wrap the key in quotes and replace the new lines with \n)
  • Add the password to the .env file as GLOBALID_PRIVATE_KEY_PASS

To be able to access a user's PII, you'll have to follow the OpenID Connect Flow above - you must have included an acrc_id that authorizes PII sharing when following that flow.

Then, follow these steps (all the code is at the end)

6. Make another token request to get an access_token,

Make this token request using the client_credentials grant. This doesn't require a user's access token, so you can do it at any point you want.

7. Extract the claims from the JWT

The JWT key that is idp.globalid.net/claims/#{acrc_id} holds the claims values

8. Decode and decrypt the claims

Base64 decode and decrypt the claims (with the private key and private key pass sent to globaliD) each of the claims - to get the private_data_tokens

9. Get the vault responses

Make a request to the globaliD vault with the private_data_tokens in the body of the request, and parse the response JSON

10. Decode and decrypt the vault responses

Base64 decode and decrypt (with the private key and private key pass sent to globaliD) each of the vault responses encrypted_data_password - to get the decrypted_data_password

11. Split the Vault data into the Initialization Vector and the Encrypted data

The first 32 bytes are the Initialization Vector (iv). The following bytes is the actual encrypted data that you want to decrypt.

12. Decrypt the AES encrypted data

The decrypted_data_password and the iv need to be Hex encoded and applied to an AES 256 CBC Cipher to decrypt the PII.

This is code you can paste into IRB

This covers steps 6 through 12, provided you followed the OpenID Connect flow in IRB

# Load the private key with Ruby's encryption library
private_key = OpenSSL::PKey::RSA.new(ENV["GLOBALID_PRIVATE_KEY"], ENV["GLOBALID_PRIVATE_KEY_PASS"])

# We only care about the *encrypted_claims_token* part of the decoded_token - but keys are dynamically named
# The decoded_token has a key-value pair with a key that is `idp.globalid.net/claims/#{acrc_id}`
# The value from this key value pair will itselv be key-value pairs, with values of the encrypted_data_tokens for the vault
# Parsing this is a pain, we find matching keys, grab values for those keys, and then grab the values of those values :/
claims_key_values = decoded_token.select { |k, v| k.match?(ENV["ACRC_ID"]) }
encrypted_data_tokens = claims_key_values.values.map(&:values).flatten
# Get the tokens to make requests to the vault, which is how you access the PII, by decrypting the encrypted_data_tokens
decrypted_tokens = encrypted_data_tokens.map do |claim_token|
  # The claim_tokens are base64 encoded
  private_key.private_decrypt(Base64.decode64(claim_token), OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
end
# At this point you need to get an access token to make requests to the vault.
# Get this access token by making a request to the token_url using the `client_credentials` grant_type
# NOTE: the client_credentials grant_type doesn't require a user token
client_credentials_token_params = { client_id: ENV["GLOBALID_CLIENT_ID"], client_secret: ENV["GLOBALID_CLIENT_SECRET"], grant_type: "client_credentials", redirect_uri: ENV["REDIRECT_URL"] }
client_credentials_response = openid_response = Faraday.new(url: token_url).post do |req|
  req.headers["Content-Type"] = "application/x-www-form-urlencoded"
  req.body = URI.encode_www_form(client_credentials_token_params)
end
# client_credentials_response body is JSON that looks like this: { access_token: "...", token_type: "bearer", expires_in: 7200, refresh_token: nil }
access_token = JSON.parse(client_credentials_response.body)["access_token"] # this is the only part of the client_credentials_response we use

# We now have all the data we need to be able to make requests to the vault!
# Make a request to the vault, using the access token, with the decrypted tokens:
vault_response = Faraday.new(url: "https://api.global.id/v1/vault/get-encrypted-data").post do |req|
  req.headers["Authorization"] = "Bearer #{access_token}"
  req.headers["Content-Type"] = "application/json"
  req.body = { private_data_tokens: decrypted_tokens }.to_json
end

# The vault_response body potentially has multiple responses, so we need to decrypt each of them:
pii_key_values = JSON.parse(vault_response.body).map do |vault_data|
  # Decrypt the password for the vault data
  decrypted_data_password = private_key.private_decrypt(Base64.decode64(vault_data["encrypted_data_password"]), OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
  # The Initialization Vector is the first 32 bytes of the encrypted data
  iv = vault_data["encrypted_data"][0, 32]
  # The actual encrypted data is everything after the first 32 bytes
  encrypted_data = vault_data["encrypted_data"][32, vault_data["encrypted_data"].length]
  # Create a cipher that can decrypt the data that was encrypted in the vault
  cipher = OpenSSL::Cipher::Cipher.new("aes-256-cbc")
  cipher.decrypt # Tell the cipher instance that we are going to decrypt with it
  # The password and the IV are hex encoded
  cipher.key = Array(decrypted_data_password).pack("H*") # Encode the password in hex (base16)
  cipher.iv = Array(iv).pack("H*") # the initialization vector (iv) is first 32 chars of the encoded_data, hex encoded
  # Decode the base64 encoded data, and decrypt it!
  decrypted_pii = cipher.update(Base64.decode64(encrypted_data)) + cipher.final
  JSON.parse(decrypted_pii)
end

# And there you have it! Simple ;)
pp pii_key_values