Authorization Code flow
This is the most common and most secure oauth flow, and we recommend using it whenever applicable. According to the latest OAuth 2.0 Security Best Current Practice document we RECOMMEND clients to use the Authorization Code flow with Proof Key for Code Exchange (PKCE) even for confidential clients
High level overview with actors
- Create an OAuth client
Go to the account section by clicking your user avatar at the bottom of the left bar and then selecting Account -> OAuth -> Register Client.
Actor: most probably a client developer/admin - Make the authorization request. Actor: client codebase
- When prompted for login, sign-in with your user credentials or federated login (SAML).
Actor: end-user/resource-owner - When prompted for end-user consent overview permissions and provide grant.
Actor: end-user/resource-owner - Make the access token request.
Actor: client codebase - Use the issued access_token on the resource server/any applicable place, for example on our public API. Actor: client codebase
Low level steps:
Step 1 - OAuth client creation
You can follow the online documentation to create an OAuth client in Timeline. Mind, that for security reasons every resource-owner who will provide the OAuth grant needs to be part of the Account in-which this client is being created. This rule is necessary because every cloud-based Timeline installment is multi-tenant.
When the client has been created you will be provided with sensitive credentials. The Client ID and Client secret are opaque keys which you need to persist into your system and store them securely. The easiest way to do that is to create a UI page into you application where the integrator (who creates the OAuth client in Timeline) can copy&paste these values into a form. Needless to say such application should operate over TLS so no men-in-the-middle (or similar) attacks can sniff these credentials. When your application will later initiate the OAuth Authorization request you will need some information. These are
- client_id
- client_secret
- issuer
- authorize endpoint path
You got the id & secret from the created client, and you can get the rest by either parsing the metadata document, or copy&pasting that information to the form by hand. Having said that, we recommend, in the form you either have a field for the metadata URI and your backend system will parse the metadata and extract the issuer and other information out from it, or create multiple inputs for the respective information in the form.
You can find the metadata document using the path below, depending on your instance:
https://{your.timeline.instance}.com/api/auth/oauth/.well-known/oauth-authorization-server
Step 2 - Authorization request
Depending on your business requirements you will need to fetch some information about a Timeline user at some point in their journey in your application. When you need to call Timeline's public API you have to check if the actual end-user have a valid accessToken in your system or not. If there is no available accessToken for your user, you have to trigger the OAuth Authorization Code flow. You do that by making the authorization request.
Specification: https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1 If the PKCE addition has been enabled on the oauth client you also have to provide PKCE properties. Specification: https://datatracker.ietf.org/doc/html/rfc7636#section-4
In short, you will have to generate a code_verifier, using that you will generate a code_challange and a code_challange_method. If you can, generate these on the backend. You will have to send the code_challange and the code_challange_method with the request while the code_verifier has to be saved to a store (im memory store, database, ...etc).
You will have to redirect your end-user's browser agent to the constructed authorize URI. Let's see how to construct it.
Base URL: <ISSUER>/api/auth/authorize
Query string params:
response_type - REQUIRED - code
client_id - REQUIRED - read it out from your database/store. In step 1, you saved this by a form post
redirect_uri - REQUIRED - in step 1, when creating the oauth client in Timeline, you had to provide a "Redirect URI" which is (probably) an endpoint URI of your backend. Commonly something like <YOUR_SERVER_BASE_URL>/api/oauth/callback or similar. It has to be a 1:1 match.
scope - OPTIONAL - when creating the oauth client you had to select a few (or every) scopes according to your needs. Those define the absolute set of scopes your client might be able to use. Here, with every authorize request you have the possibility to "loosen" the scopes which are needed for this specific use-case of yours. That is, with this parameter you can send just a subset of the scopes selected upon client creation. If left blank, you will request an accessToken with all the scopes selected upon client creation. Mind, that you should only request a minimal set of scopes which are absolutely necessary for your current use-case as requesting access for scopes which are unrelated for your use-case might result in a rejected grant by your end-user. For example, if your current use-case is to list the end-users projects in Timeline you only need the project:read scope and if you'd request the repository:read scope as well, your end-user might find it fraudulent on the consent screen to provide permission for her repositories when all is required are her projects.
state - OPTIONAL - since the authorization code flow is a redirection based flow you user will leave your application, and she will be redirected to Timeline for authentication and to provide grant. At the end she will be redirected back to your "Redirect URI". There, you will have the option to "resume" the user to continue where she "left off". But you won't necessarily know where the user left-off her session. You can use this parameter to encode any information which could help you to resume the user. As per specification whatever you send in this parameter will be sent back un-altered with the response
login_hint - OPTIONAL - this has been borrowed from the OIDC specification. If you are able to identify your end-user prior to the authorization request you can send the user's email address (make sure to URL encode it) with this parameter, and optionally it might ease your end-user's experience at Timeline. For example, any federated authentication can be improved knowing the user beforehand of authentication.
nonce - OPTIONAL - when doing an OpenID Connect flow you can send a nonce parameter which is very similar to the "state" parameter, except, this one will be included unmodified in the id_token while the state value will be sent back as a query parameter
With all that, you can construct the URI
const params = new URLSearchParams(); params.set('response_type', 'code'); params.set('redirect_uri', 'https://example.com/api/oauth/callback'); params.set('scope', 'project:read project:write'); params.set('client_id', 'example-client-id'); params.set('state', 'example-state'); if (isPKCEEnabled) { params.set('code_challenge', 'example-code-challenge'); params.set('code_challenge_method', 'S256'); } if (isNonceEnabled) { params.set('nonce', 'example-nonce'); } window.location.assign(`${authorizationEndpointUrl}?${params.toString()}`);
Step 3 - End-user authentication & grant
At this point your end-user's (resource owner) browser will be redirected to the "authorize" endpoint. As per specification Timeline will
- Check if the resource owner has a valid session with Timeline (a.k.a is the user logged in to Timeline?). If not, we will challange the user for authentication
- If authenticated, we will redirect the resource owner's browser to the consent page to collect resource owner grant
Note. the consent page is a UI page where we list the requested resources for which your application wants to get access (grant) from the resource owner. The resource owner can overview the requested permissions, and she can deny some, or all of them. If she denies only some of them, you will still get the grant but when obtaining the accessToken on the token endpoint the issued accessToken Won't have all the requested scopes only those which the resource owner allowed. This is why you should only request a minimal set of scopes with the authorize request.
If the resource owner clicks on the "Allow" button on the consent screen she provided "grant" and we will redirect her browser agent back to "Redirect URI" endpoint with a query parameter of "code". The value is an opaque string without holding any information to you. This string is the "grant" and you can exchange it for an accessToken on the token endpoint. Mind, that you have 1 minute to exchange the code for an accessToken. For example, we do a HTTP 302 <redirect_uri>?code=aa-bbb-qwerty
Note. all of these redirects were front channel communication over a browser agent. When we provide the grant by a redirect to your "Redirect URI" endpoint you have to exchange the grant for an accessToken on the back channel. That is, you have to call the token endpoint with an XHR call and NOT with a browser redirect, or in other words, exchanging the code to an accessToken should NOT be visible to the end-user.
On the possible errors you can get, check the Troubleshooting section.
Step 4 - Exchanging the grant
Parse the code value, this is called the "grant". You have to call the token endpoint and send this value to obtain an access_token. We suggest you do this on your server side component. Mind, that if you made the authorize request with PKCE you will have to send the saved code_verifier you used to generate the code_challange value.
const params = new URLSearchParams(); params.set('grant_type', 'authorization_code'); params.set('redirect_uri', 'https://example.com/api/oauth/callback'); params.set('code', 'aa-bbb-qwerty'); if (isPKCEEnabled) { params.set('code_verifier', '<your code verifier from the previous steps>'); } let auth; if (isPublicClient) { // Public clients must send client_id params.set('client_id', 'example-client-id'); } else { // Confidential clients must do client authentication auth = { username: clientID, password: clientSecret }; } const { data: tokenRecord } = await axios.post(tokenEndpointUrl, params.toString(), { auth, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
On the possible errors you can get, check the Troubleshooting section.
Step 5 - Using the issued accessToken
In the previous step, we got the tokenRecord as response. Let's investigate
{ "access_token": "b2F1dGg6MzU1Nzc2MWQtMGIxMi00MDA5LWEwYmEtNTljMWQ3ZDc4OWI3OjI0MzkxZTMxLWE5ZjQtNDNkOC05Y2FhLWYzMWMzMTJkYzhiZQ==", "token_type": "bearer", "expires_at": "2023-09-07T11:51:02.286Z", "expires_in": 86400000, "scope": "project:read project:write repository:read repository:write zoneinfo", }
These are the bare minimum fields which will be included in tokenRecord. Depending on the OAuth client you created additional fields might be present. If you enabled the "Refresh Token" flow on the client additional fields will appear
{ "access_token": "b2F1dGg6MzU1Nzc2MWQtMGIxMi00MDA5LWEwYmEtNTljMWQ3ZDc4OWI3OjI0MzkxZTMxLWE5ZjQtNDNkOC05Y2FhLWYzMWMzMTJkYzhiZQ==", "token_type": "bearer", "expires_at": "2023-09-07T11:51:02.286Z", "expires_in": 86400000, "scope": "project:read project:write repository:read repository:write zoneinfo", "refresh_token": "ZWUwMDc0NmMtMDJkZi00YzE4LWEyMjUtNzNmZjQxNTQ3OTMzOmNlOTg0MDZlLTFjOWQtNGU2NC04M2UyLTE0MjQyYmZjNTQyZQ==", "refresh_token_expires_at": "2023-09-13T11:51:02.418Z", "refresh_token_expires_in": 604800000 }
If you also enable "OpenID-Connect" then an id_token will be also present with additional OIDC claims
{ "access_token": "b2F1dGg6MzU1Nzc2MWQtMGIxMi00MDA5LWEwYmEtNTljMWQ3ZDc4OWI3OjI0MzkxZTMxLWE5ZjQtNDNkOC05Y2FhLWYzMWMzMTJkYzhiZQ==", "token_type": "bearer", "expires_at": "2023-09-07T11:51:02.286Z", "expires_in": 86400000, "scope": "email family_name given_name locale name openid phone_number project:read project:write repository:read repository:write zoneinfo", "refresh_token": "ZWUwMDc0NmMtMDJkZi00YzE4LWEyMjUtNzNmZjQxNTQ3OTMzOmNlOTg0MDZlLTFjOWQtNGU2NC04M2UyLTE0MjQyYmZjNTQyZQ==", "refresh_token_expires_at": "2023-09-13T11:51:02.418Z", "refresh_token_expires_in": 604800000, "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJmYW1pbHlfbmFtZSI6IktsaW5nIiwiZ2l2ZW5fbmFtZSI6IkF0dGlsYSIsImxvY2FsZSI6ImVuLVVTIiwibmFtZSI6IkF0dGlsYSBLbGluZyIsInBob25lX251bWJlciI6IiszNjIwMzk3OTM4NyIsInN1YiI6IjEyIiwiaWF0IjoxNjk0MDAxMDYyLCJpc3MiOiJodHRwczovL2xvY2FsaG9zdDoxMzM4L2FwaS9hdXRoIiwiYXVkIjoiZDBmZThhMmJhNmZmZGI3ZTIxNTYxYjEzMzg5MTViMmQiLCJleHAiOjE2OTQwODc0NjI1MjZ9.Wkgd9FFMpKOfysxzmYSdfQJDzMN9D17k6i5P8aZgCBDDmq_f1L0w5tehe2JV2AEhi3gsNZya5e6eUkjZWWmS6MaCECi9uL8BbR2xHNtErteDQZiK6rx1LAIshrBAFb_U3ENGtVOoUxFcxGm-hE2UER6oXKMNRS0EbXymxY_PzmhvShy9KRDSpO08VA7giIzvV6zi-w6fHnvJ7ZlGSSyMvDpmcP-g3x_SJ_An6v4nlu-0TEcGdb9VQhyN1VNF1EpdCuBdoPBJ35UlXQucv3S8y4dlfOYY2QJsGXUqv6Cumku3t-jUVvmbhyPV2-XBv6BEmMwufxTuNX_mVwVdqj27sKkDNCQEDZVAUOYwMDaYLiPTPr33YCOuN9tJ4lUzS_8M-cr1-vX88FerKOoVIKiMVgu7xcYzHnUOM_m4pNZOV9v4XRGI9lt-jmcZyaGHy2sTTs4SKZAMVAkYWFVjrGoTNwjwKzSavPUfIIIucARsIGwlQdY6aHLmvSzCDmxjTWLU4nZxhvsi3R3caJyHokT8AcHuKrCNVpCd4EHp1rtQDAPTP6nMM6y62aSe3jQcUpSSnDpVFTw-4RCEVn_aQWO6TtS1Z3UQZyVO1vlYGu7QSvLjrSKSnohECm4GWw-lC-VppQhBnFQFKHeWPqNw6rBKDmN6vJ__d6iphbDLUy_5iE4" }
Note. please be mindful of the scope property of the response, it is possible that the access_token doesn't contain all the scopes which you requested because the resource owner denied some of them on the consent screen. With the scope property you can check what scopes the resource owner allowed.
Now you just have to send the access_token value as an Authorization: Bearer token alongside of the public API requests to be authenticated.
If any time you have to check if an access_token is valid you can use the introspect token endpoint. Specification: https://datatracker.ietf.org/doc/html/rfc7662.
URL: <ISSUER>/api/auth/introspect
You have to make a POST request with Content-Type = application/x-www-form-urlencoded with fields:
token - REQUIRED - an access_token
token_type_hint - OPTIONAL - access_token or refresh_token
You also have to do Client Authentication which means you have to send your client's client_id & client_secret as Authentication: Basic header.
Important. To prevent token scanning attacks, the endpoint MUST also require some form of authorization to access this endpoint, such as client authentication as described in OAuth 2.0 [RFC6749]
If the token is no longer active you'd get
{ "active": false }
otherwise
{ "active": true, "exp": 1694174721, "token_type": "access_token", "username": "attila.kling@abbyy.com", "sub": 2, "client_id": "d0fe8a2ba6ffdb7e21561b1338915b2d", "scope": "email family_name given_name locale name openid phone_number project:read project:write repository:read repository:write zoneinfo" }
Troubleshooting
Per the OAuth specification upon the Authorize request
Important. If the request fails due to a missing, invalid, or mismatching redirection URI, or if the client identifier is missing or invalid, the authorization server SHOULD inform the resource owner of the error and MUST NOT automatically redirect the user-agent to the invalid redirection URI.
The resource owner will see an error page explaining the issue, and they won't be redirected to your "Redirect URI" endpoint. In other error cases we will redirect the user to "Redirect URI" with the error, error_description and optionally error_uri, error_code and state query parameters. In case of the Public API, you might get an error response with the body of
{ "error": "some_error", "error_description": "some_error_description", "error_params": {}, "error_uri": "some_error_uri" }
Let's see what kind of errors you can get
error | error_description | notes |
---|---|---|
SERVER_ERROR | The authorization server encountered an unexpected condition that prevented it from fulfilling the request | |
INVALID_SCOPE | Invalid scope <scope(s)> | If you provide an invalid, non-existing scope with the authorize request |
INVALID_CLIENT | The client is not authorized to request an authorization code using this method | If you provide an invalid client_id upon client authentication, or if the client_secret does not match |
INVALID_CLIENT | The client is not registered | |
INVALID_CLIENT | A public client should not be able to do client authentication with the authorization server | You shouldn't try to do client authentication with a public client. Public clients doesn't have a client_secret |
INVALID_USER | The authenticated user doesn't have access for the specified oauth client | When the resource owner autheticates she is only allowed to login with a user who is part of the same account in which the oauth client has been created |
UNAUTHORIZED_CLIENT | Public clients are forbidden from using authentication method: ${authorizationMethod} | A public client attempted to do client authentication with the Basic or Client Password scheme |
UNAUTHORIZED_CLIENT | Confidential clients MUST authenticate with the authorization server | |
UNAUTHORIZED_CLIENT | The authenticated client is not authorized to use this grant type | Make sure to use only those grant types which are enabled on the oauth client |
UNSUPPORTED_GRANT_TYPE | The authorization grant type is not supported by the authorization server | We only support authorization_code, refresh_token and client_credentials |
UNSUPPORTED_RESPONSE_TYPE | Unsupported response_type | We only support code response type |
INVALID_GRANT | Invalid or expired authorization code! | https://tools.ietf.org/html/rfc6749#section-10.5 |
INVALID_GRANT | Code challenge failed! | |
INVALID_GRANT | ClientID or RedirectURI does not match with the issued authorization code! | Security measure to not let a code to be exchanged if the code was issued for a different oauth client |
INVALID_REQUEST | URI must not contain a fragment | https://www.rfc-editor.org/rfc/rfc6749#section-3.1 |
INVALID_REQUEST | The use of TLS is mandatory | https://www.rfc-editor.org/rfc/rfc6749#section-3.1 |
INVALID_REQUEST | Must use HTTP POST or GET | https://www.rfc-editor.org/rfc/rfc6749#section-3.1 |
INVALID_REQUEST | Unsupported code challenge method | Supported: sha256, s256, plain |
INVALID_REQUEST | Redirect URI mismatch! | You have to send an exact match with the provided value on OAuth client registration |
INVALID_REQUEST | Duplicated query parameter(s): | |
INVALID_REQUEST | Missing required parameter(s): | |
INVALID_REQUEST | Invalid request parameter(s): | |
ACCESS_DENIED | The resource owner or authorization server denied the request | When the resource owner clicked on "Deny" on the consent screen denoting that she doesn't want to allow access |
INVALID_ACCESS_TOKEN | Invalid Access Token! | For the userinfo endpoint if a not existing or invalid access_token has been sent |
EXPIRED_ACCESS_TOKEN | Access Token has expired! | |
INVALID_REFRESH_TOKEN | Invalid Refresh Token! | |
EXPIRED_REFRESH_TOKEN | Refresh Token has expired! | |
INSUFFICIENT_SCOPE | Insufficient scope for requested resource! | |
UNSUPPORTED_ACCESS_TOKEN | The provided access token, granted by the client_credentials grant type, can not be used to fulfill the request | In the public API we don't support the client_credentials grant |
Entity | TTL | Notes |
---|---|---|
access_token | 1 day | |
refresh_token | 1 week | |
refresh_token_abs | 1 month | When refreshing an access_token with a refresh_token, we will provide a new refresh_token with a new TTL but there is an absolute time when you have to do the OAuth flow again |
id_token | 1 day | |
authorization_code | 1 min |
05.09.2024 16:23:54