Skip to content

Commit d9ae73d

Browse files
committed
feat: add mapLoginMethodParamsForUrl and improved typing and default values.
1 parent 1f3be66 commit d9ae73d

File tree

3 files changed

+212
-88
lines changed

3 files changed

+212
-88
lines changed

lib/main.test.ts

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { base64UrlEncode, sanitizeRedirect, mapLoginMethodParamsForUrl, generateAuthUrl } from './main';
3+
import { LoginMethodParams, IssuerRouteTypes, LoginOptions, Scopes } from './types';
4+
5+
describe('base64UrlEncode', () => {
6+
it('should encode a string to base64 URL safe format', () => {
7+
const input = 'test string';
8+
const expectedOutput = 'dGVzdCBzdHJpbmc';
9+
expect(base64UrlEncode(input)).toBe(expectedOutput);
10+
});
11+
});
12+
13+
describe('sanitizeRedirect', () => {
14+
it('should remove trailing slash from URL', () => {
15+
const input = 'https://example.com/';
16+
const expectedOutput = 'https://example.com';
17+
expect(sanitizeRedirect(input)).toBe(expectedOutput);
18+
});
19+
20+
it('should return the same URL if no trailing slash', () => {
21+
const input = 'https://example.com';
22+
const expectedOutput = 'https://example.com';
23+
expect(sanitizeRedirect(input)).toBe(expectedOutput);
24+
});
25+
});
26+
27+
describe('mapLoginMethodParamsForUrl', () => {
28+
it('should map login method params to URL params', () => {
29+
const options: Partial<LoginMethodParams> = {
30+
loginHint: '[email protected]',
31+
isCreateOrg: true,
32+
connectionId: 'conn123',
33+
redirectURL: 'https://example.com/',
34+
audience: 'audience123',
35+
};
36+
const expectedOutput = {
37+
login_hint: '[email protected]',
38+
is_create_org: 'true',
39+
connection_id: 'conn123',
40+
redirect_uri: 'https://example.com',
41+
audience: 'audience123',
42+
scope: "email profile openid offline"
43+
};
44+
expect(mapLoginMethodParamsForUrl(options)).toEqual(expectedOutput);
45+
});
46+
47+
it('should handle undefined values in options', () => {
48+
const options: Partial<LoginMethodParams> = {};
49+
const expectedOutput = {
50+
scope: "email profile openid offline"
51+
};
52+
expect(mapLoginMethodParamsForUrl(options)).toEqual(expectedOutput);
53+
});
54+
});
55+
56+
describe('generateAuthUrl', () => {
57+
it('should generate the correct auth URL with required parameters', () => {
58+
const domain = 'https://auth.example.com';
59+
const options: LoginOptions = {
60+
clientId: 'client123',
61+
responseType: 'code',
62+
scope: [Scopes.openid, Scopes.profile],
63+
loginHint: '[email protected]',
64+
isCreateOrg: true,
65+
connectionId: 'conn123',
66+
redirectURL: 'https://example.com',
67+
audience: 'audience123',
68+
prompt: 'login',
69+
};
70+
const expectedUrl = 'https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&login_hint=user%40example.com&is_create_org=true&connection_id=conn123&redirect_uri=https%3A%2F%2Fexample.com&audience=audience123&scope=openid+profile&prompt=login';
71+
72+
const result = generateAuthUrl(domain, IssuerRouteTypes.login, options);
73+
expect(result.toString()).toBe(expectedUrl);
74+
});
75+
76+
it('should include optional parameters if provided', () => {
77+
const domain = 'https://auth.example.com';
78+
const options: LoginOptions = {
79+
clientId: 'client123',
80+
responseType: 'code',
81+
scope: [Scopes.openid, Scopes.profile],
82+
state: 'state123',
83+
codeChallenge: 'challenge123',
84+
codeChallengeMethod: 'S256',
85+
redirectURL: 'https://example2.com',
86+
prompt: 'create',
87+
};
88+
const expectedUrl = 'https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&redirect_uri=https%3A%2F%2Fexample2.com&scope=openid+profile&prompt=create&state=state123&code_challenge=challenge123&code_challenge_method=S256';
89+
90+
const result = generateAuthUrl(domain, IssuerRouteTypes.login, options);
91+
expect(result.toString()).toBe(expectedUrl);
92+
});
93+
94+
it('should handle default responseType if not provided', () => {
95+
const domain = 'https://auth.example.com';
96+
const options: LoginOptions = {
97+
clientId: 'client123',
98+
scope: [Scopes.openid, Scopes.profile, Scopes.offline_access],
99+
redirectURL: 'https://example2.com',
100+
prompt: 'create',
101+
};
102+
const expectedUrl = 'https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&redirect_uri=https%3A%2F%2Fexample2.com&scope=openid+profile+offline_access&prompt=create';
103+
104+
const result = generateAuthUrl(domain, IssuerRouteTypes.login, options);
105+
expect(result.toString()).toBe(expectedUrl);
106+
});
107+
});

lib/main.ts

+28-79
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
// import { getRandomValues,subtle } from "uncrypto";
2-
import { IssuerRouteTypes, LoginOptions } from "./types";
1+
import { IssuerRouteTypes, LoginMethodParams, LoginOptions } from "./types";
32

43
/**
54
*
@@ -21,6 +20,28 @@ export const sanitizeRedirect = (url: string): string => {
2120
return url.replace(/\/$/, "");
2221
};
2322

23+
export const mapLoginMethodParamsForUrl = (
24+
options: Partial<LoginMethodParams>,
25+
): Record<string, string> => {
26+
const translate: Record<string, string | undefined> = {
27+
login_hint: options.loginHint,
28+
is_create_org: options.isCreateOrg?.toString(),
29+
connection_id: options.connectionId,
30+
redirect_uri: options.redirectURL ? sanitizeRedirect(options.redirectURL) : undefined,
31+
audience: options.audience,
32+
scope: options.scope?.join(" ") || 'email profile openid offline',
33+
prompt: options.prompt,
34+
lang: options.lang,
35+
org_code: options.orgCode,
36+
org_name: options.orgName,
37+
};
38+
39+
Object.keys(translate).forEach(
40+
(key) => translate[key] === undefined && delete translate[key],
41+
);
42+
return translate as Record<string, string>;
43+
};
44+
2445
/**
2546
*
2647
* @param options
@@ -35,14 +56,16 @@ export const generateAuthUrl = (
3556
const authUrl = new URL(`${domain}/oauth2/auth`);
3657

3758
const searchParams: Record<string, string> = {
38-
redirect_uri: sanitizeRedirect(options.callbackURL),
3959
client_id: options.clientId,
4060
response_type: options.responseType || "code",
41-
scope: options.scope.join(" "),
42-
state: options.state,
4361
start_page: type,
62+
...mapLoginMethodParamsForUrl(options),
4463
};
4564

65+
if (options.state) {
66+
searchParams["state"] = options.state;
67+
}
68+
4669
if (options.codeChallenge) {
4770
searchParams["code_challenge"] = options.codeChallenge;
4871
searchParams["code_challenge_method"] = "S256";
@@ -52,81 +75,7 @@ export const generateAuthUrl = (
5275
searchParams["code_challenge_method"] = options.codeChallengeMethod;
5376
}
5477

55-
if (options.audience) {
56-
searchParams["audience"] = options.audience;
57-
}
58-
5978
authUrl.search = new URLSearchParams(searchParams).toString();
6079
return authUrl;
6180
};
6281

63-
// /**
64-
// * Creates a random string of provided length.
65-
// * @param {number} length
66-
// * @returns {string} required secret
67-
// */
68-
// export const generateRandomString = (length: number = 28): string => {
69-
// const bytesNeeded = Math.ceil(length / 2);
70-
// const array = new Uint32Array(bytesNeeded);
71-
// getRandomValues(array);
72-
// let result = Array.from(array, (dec) =>
73-
// ("0" + dec.toString(16)).slice(-2),
74-
// ).join("");
75-
// if (length % 2 !== 0) {
76-
// // If the requested length is odd, remove the last character to adjust the length
77-
// result = result.slice(0, -1);
78-
// }
79-
// return result;
80-
// };
81-
82-
// //////
83-
84-
// /**
85-
// *
86-
// * @param code_verifier Verifier to generate challenge from
87-
// * @returns URL safe base64 encoded string
88-
// */
89-
// export async function pkceChallengeFromVerifier(
90-
// code_verifier: string,
91-
// ): Promise<string> {
92-
// const hashed = await sha256(code_verifier);
93-
// const hashedString = Array.from(new Uint8Array(hashed))
94-
// .map((byte) => String.fromCharCode(byte))
95-
// .join("");
96-
// return base64UrlEncode(hashedString);
97-
// }
98-
99-
// /**
100-
// * setups up PKCE challenge
101-
// * @returns
102-
// */
103-
// export const setupChallenge = () => {
104-
// return { state: generateRandomString(), ...pkceChallenge() };
105-
// };
106-
107-
// /**
108-
// * Calculate the SHA256 hash of the input text.
109-
// * @param plain the text to hash
110-
// * @returns a promise that resolves to an ArrayBuffer
111-
// */
112-
// export const sha256 = (plain: string) => {
113-
// const encoder = new TextEncoder();
114-
// const data = encoder.encode(plain);
115-
// return subtle.digest("SHA-256", data);
116-
// };
117-
118-
// export async function generateChallenge(code_verifier: string) {
119-
// return (await sha256(code_verifier)).toString();
120-
// }
121-
122-
// /**
123-
// *
124-
// * @returns
125-
// */
126-
// export const pkceChallenge = async (): Promise<PKCEChallenge> => {
127-
// const codeVerifier = generateRandomString();
128-
// return {
129-
// codeVerifier,
130-
// codeChallenge: await generateChallenge(codeVerifier),
131-
// };
132-
// };

lib/types.ts

+77-9
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,90 @@ export type LoginMethodParams = Pick<
2020
>;
2121

2222
export type LoginOptions = {
23+
/** Audience to include in the token */
2324
audience?: string;
25+
/** Client ID of the application
26+
*
27+
* This can be found in the application settings in the Kinde dashboard
28+
*/
2429
clientId: string;
30+
/**
31+
* Code challenge for PKCE
32+
*/
2533
codeChallenge?: string;
34+
/**
35+
* Code challenge method for PKCE
36+
*/
2637
codeChallengeMethod?: string;
38+
/**
39+
* Connection ID to use for the login
40+
*
41+
* This is found in the authentication settings in the Kinde dashboard
42+
*/
2743
connectionId?: string;
28-
isCreateOrg: string;
29-
lang: string;
30-
loginHint: string;
31-
orgCode: string;
32-
orgName: string;
44+
/**
45+
* Whether the user is creating an organization on registration
46+
*/
47+
isCreateOrg?: boolean;
48+
/**
49+
* Language to use for the login in 2 letter ISO format
50+
*/
51+
lang?: string;
52+
/**
53+
* Login hint to use for the login
54+
*
55+
* This can be in one of the following formats:
56+
57+
* - phone:+447700900000:gb
58+
* - username:joebloggs
59+
*/
60+
loginHint?: string;
61+
/**
62+
* Organization code to use for the login
63+
*/
64+
orgCode?: string;
65+
/**
66+
* Organization name to be used when creating an organization at registration
67+
*/
68+
orgName?: string;
69+
/**
70+
* Prompt to use for the login
71+
*
72+
* This can be one of the following:
73+
* - login
74+
* - create
75+
* - none
76+
*
77+
*/
3378
prompt: string;
79+
/**
80+
* Redirect URL to use for the login
81+
*/
3482
redirectURL: string;
35-
responseType: string;
36-
scope: Scopes[];
37-
state: string;
38-
callbackURL: string;
83+
/**
84+
* Response type to use for the login
85+
*
86+
* Kinde currently only supports `code`
87+
*/
88+
responseType?: string;
89+
/**
90+
* Scopes to include in the token
91+
*
92+
* This can be one or more of the following:
93+
* - email
94+
* - profile
95+
* - openid
96+
* - offline_access
97+
*/
98+
scope?: Scopes[];
99+
/**
100+
* State to use for the login
101+
*/
102+
state?: string;
103+
/**
104+
* Whether to show the complete screen at the end of the flow, this is most useful when the callback is not a webpage.
105+
*/
106+
showCompleteScreen?: boolean;
39107
};
40108

41109
export enum IssuerRouteTypes {

0 commit comments

Comments
 (0)