Skip to content

Commit 0f2f042

Browse files
CIBA implementation (#1066)
1 parent e8b6347 commit 0f2f042

3 files changed

Lines changed: 543 additions & 0 deletions

File tree

src/auth/backchannel.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
// Wednesday, 8 January, 2025
2+
// Client Initiated Backchannel Authentication (CIBA)
3+
4+
// CIBA is an OpenID Foundation standard for a decoupled authentication flow. It enables
5+
// solution developers to build authentication flows where the user logging in does not do so
6+
// directly on the device that receives the ID or access tokens (the “Consumption Device”), but
7+
// instead on a separate “Authorization Device”.
8+
9+
import { JSONApiResponse } from '../lib/models.js';
10+
import { BaseAuthAPI } from './base-auth-api.js';
11+
12+
/**
13+
* The response from the authorize endpoint.
14+
*/
15+
export type AuthorizeResponse = {
16+
/**
17+
* The authorization request ID.
18+
*/
19+
auth_req_id: string;
20+
/**
21+
* The duration in seconds until the authentication request expires.
22+
*/
23+
expires_in: number;
24+
/**
25+
* The interval in seconds to wait between poll requests.
26+
*/
27+
interval: number;
28+
};
29+
30+
type AuthorizeCredentialsPartial = {
31+
client_id: string;
32+
client_secret?: string;
33+
client_assertion?: string;
34+
client_assertion_type?: string;
35+
};
36+
37+
/**
38+
* The login hint containing information about the user for authentication.
39+
*/
40+
type LoginHint = {
41+
/**
42+
* The format of the login hint.
43+
*/
44+
format: 'iss_sub';
45+
/**
46+
* The issuer URL.
47+
*/
48+
iss: string;
49+
/**
50+
* The subject identifier.
51+
*/
52+
sub: string;
53+
};
54+
55+
/**
56+
* Generates the login hint for the user.
57+
*
58+
* @param {string} userId - The user ID.
59+
* @param {string} domain - The tenant domain.
60+
* @returns {string} - The login hint as a JSON string.
61+
*/
62+
const getLoginHint = (userId: string, domain: string): string => {
63+
// remove trailing '/' from domain, added later for uniformity
64+
const trimmedDomain = domain.endsWith('/') ? domain.slice(0, -1) : domain;
65+
const loginHint: LoginHint = {
66+
format: 'iss_sub',
67+
iss: `https://${trimmedDomain}/`,
68+
sub: `${userId}`,
69+
};
70+
return JSON.stringify(loginHint);
71+
};
72+
73+
/**
74+
* Options for the authorize request.
75+
*/
76+
export type AuthorizeOptions = {
77+
/**
78+
* A human-readable string intended to be displayed on both the device calling /bc-authorize and the user’s authentication device.
79+
*/
80+
binding_message: string;
81+
/**
82+
* A space-separated list of OIDC and custom API scopes.
83+
*/
84+
scope: string;
85+
/**
86+
* Unique identifier of the audience for an issued token.
87+
*/
88+
audience?: string;
89+
/**
90+
* Custom expiry time in seconds for this request.
91+
*/
92+
request_expiry?: string;
93+
/**
94+
* The user ID.
95+
*/
96+
userId: string;
97+
/**
98+
* Optional parameter for subject issuer context.
99+
*/
100+
subjectIssuerContext?: string;
101+
};
102+
103+
type AuthorizeRequest = Omit<AuthorizeOptions, 'userId'> &
104+
AuthorizeCredentialsPartial & {
105+
login_hint: string;
106+
};
107+
108+
/**
109+
* The response from the token endpoint.
110+
*/
111+
export type TokenResponse = {
112+
/**
113+
* The access token.
114+
*/
115+
access_token: string;
116+
/**
117+
* The refresh token, available with the `offline_access` scope.
118+
*/
119+
refresh_token?: string;
120+
/**
121+
* The user's ID Token.
122+
*/
123+
id_token: string;
124+
/**
125+
* The token type of the access token.
126+
*/
127+
token_type?: string;
128+
/**
129+
* The duration in seconds that the access token is valid.
130+
*/
131+
expires_in: number;
132+
/**
133+
* The scopes associated with the token.
134+
*/
135+
scope: string;
136+
};
137+
138+
/**
139+
* Options for the token request.
140+
*/
141+
export type TokenOptions = {
142+
/**
143+
* The authorization request ID.
144+
*/
145+
auth_req_id: string;
146+
};
147+
148+
type TokenRequestBody = AuthorizeCredentialsPartial & {
149+
auth_req_id: string;
150+
grant_type: string;
151+
};
152+
153+
/**
154+
* Interface for the backchannel authentication.
155+
*/
156+
export interface IBackchannel {
157+
authorize: (options: AuthorizeOptions) => Promise<AuthorizeResponse>;
158+
backchannelGrant: (options: TokenOptions) => Promise<TokenResponse>;
159+
}
160+
161+
const CIBA_GRANT_TYPE = 'urn:openid:params:grant-type:ciba';
162+
const CIBA_AUTHORIZE_URL = '/bc-authorize';
163+
const CIBA_TOKEN_URL = '/oauth/token';
164+
165+
/**
166+
* Class implementing the backchannel authentication flow.
167+
*/
168+
export class Backchannel extends BaseAuthAPI implements IBackchannel {
169+
/**
170+
* Initiates a CIBA authorization request.
171+
*
172+
* @param {AuthorizeOptions} options - The options for the request.
173+
* @returns {Promise<AuthorizeResponse>} - The authorization response.
174+
*
175+
* @throws {Error} - If the request fails.
176+
*/
177+
async authorize({ userId, ...options }: AuthorizeOptions): Promise<AuthorizeResponse> {
178+
const body: AuthorizeRequest = {
179+
...options,
180+
login_hint: getLoginHint(userId, this.domain),
181+
client_id: this.clientId,
182+
};
183+
184+
await this.addClientAuthentication(body);
185+
186+
const response = await this.request.bind(this)(
187+
{
188+
path: CIBA_AUTHORIZE_URL,
189+
method: 'POST',
190+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
191+
body: new URLSearchParams(body),
192+
},
193+
{}
194+
);
195+
196+
const r: JSONApiResponse<AuthorizeResponse> = await JSONApiResponse.fromResponse(response);
197+
return r.data;
198+
}
199+
200+
/**
201+
* Handles the backchannel grant flow for authentication. Client can poll this method at regular intervals to check if the backchannel auth request has been approved.
202+
*
203+
* @param {string} auth_req_id - The authorization request ID. This value is returned from the call to /bc-authorize. Once you have exchanged an auth_req_id for an ID and access token, it is no longer usable.
204+
* @returns {Promise<TokenResponse>} - A promise that resolves to the token response.
205+
*
206+
* @throws {Error} - Throws an error if the request fails.
207+
*
208+
* If the authorizing user has not yet approved or rejected the request, you will receive a response like this:
209+
* ```json
210+
* {
211+
* "error": "authorization_pending",
212+
* "error_description": "The end-user authorization is pending"
213+
* }
214+
* ```
215+
*
216+
* If the authorizing user rejects the request, you will receive a response like this:
217+
* ```json
218+
* {
219+
* "error": "access_denied",
220+
* "error_description": "The end-user denied the authorization request or it has been expired"
221+
* }
222+
* ```
223+
*
224+
* If you are polling too quickly (faster than the interval value returned from /bc-authorize), you will receive a response like this:
225+
* ```json
226+
* {
227+
* "error": "slow_down",
228+
* "error_description": "You are polling faster than allowed. Try again in 10 seconds."
229+
* }
230+
* ```
231+
*/
232+
async backchannelGrant({ auth_req_id }: TokenOptions): Promise<TokenResponse> {
233+
const body: TokenRequestBody = {
234+
client_id: this.clientId,
235+
auth_req_id,
236+
grant_type: CIBA_GRANT_TYPE,
237+
};
238+
239+
await this.addClientAuthentication(body);
240+
241+
const response = await this.request.bind(this)(
242+
{
243+
path: CIBA_TOKEN_URL,
244+
method: 'POST',
245+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
246+
body: new URLSearchParams(body),
247+
},
248+
{}
249+
);
250+
251+
const r: JSONApiResponse<TokenResponse> = await JSONApiResponse.fromResponse(response);
252+
return r.data;
253+
}
254+
}

src/auth/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Backchannel } from './backchannel.js';
12
import { AuthenticationClientOptions } from './base-auth-api.js';
23
import { Database } from './database.js';
34
import { OAuth } from './oauth.js';
@@ -13,10 +14,12 @@ export class AuthenticationClient {
1314
database: Database;
1415
oauth: OAuth;
1516
passwordless: Passwordless;
17+
backchannel: Backchannel;
1618

1719
constructor(options: AuthenticationClientOptions) {
1820
this.database = new Database(options);
1921
this.oauth = new OAuth(options);
2022
this.passwordless = new Passwordless(options);
23+
this.backchannel = new Backchannel(options);
2124
}
2225
}

0 commit comments

Comments
 (0)