+
Skip to content

Refactor createLoginUrl() to use standardized URL APIs #71

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 54 additions & 60 deletions lib/keycloak.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// @ts-check
/**
* @import {KeycloakLoginOptions} from "./keycloak.d.ts"
*/
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
Expand Down Expand Up @@ -381,81 +385,81 @@ function Keycloak (config) {
return JSON.stringify(claims);
}

/**
* @param {KeycloakLoginOptions} options
* @returns {Promise<string>}
*/
kc.createLoginUrl = async function(options) {
var state = createUUID();
var nonce = createUUID();

var redirectUri = adapter.redirectUri(options);

var callbackState = {
state: state,
nonce: nonce,
const state = createUUID();
const nonce = createUUID();
const redirectUri = adapter.redirectUri(options);
const callbackState = {
state,
nonce,
redirectUri: encodeURIComponent(redirectUri),
loginOptions: options
};

if (options && options.prompt) {
if (options?.prompt) {
callbackState.prompt = options.prompt;
}

var baseUrl;
if (options && options.action === 'register') {
baseUrl = kc.endpoints.register();
} else {
baseUrl = kc.endpoints.authorize();
}
const baseUrl = options?.action === 'register'
? kc.endpoints.register()
: kc.endpoints.authorize();

var scope = options && options.scope || kc.scope;
let scope = options?.scope || kc.scope;
if (!scope) {
// if scope is not set, default to "openid"
scope = "openid";
} else if (scope.indexOf("openid") === -1) {
} else if (!scope.includes("openid")) {
// if openid scope is missing, prefix the given scopes with it
scope = "openid " + scope;
scope = `openid ${scope}`;
}

var url = baseUrl
+ '?client_id=' + encodeURIComponent(kc.clientId)
+ '&redirect_uri=' + encodeURIComponent(redirectUri)
+ '&state=' + encodeURIComponent(state)
+ '&response_mode=' + encodeURIComponent(kc.responseMode)
+ '&response_type=' + encodeURIComponent(kc.responseType)
+ '&scope=' + encodeURIComponent(scope);
const params = new URLSearchParams([
['client_id', kc.clientId],
['redirect_uri', redirectUri],
['state', state],
['response_mode', kc.responseMode],
['response_type', kc.responseType],
['scope', scope]
]);

if (useNonce) {
url = url + '&nonce=' + encodeURIComponent(nonce);
params.append('nonce', nonce);
}

if (options && options.prompt) {
url += '&prompt=' + encodeURIComponent(options.prompt);
if (options?.prompt) {
params.append('prompt', options.prompt);
}

if (options && typeof options.maxAge === 'number') {
url += '&max_age=' + encodeURIComponent(options.maxAge);
if (typeof options?.maxAge === 'number') {
params.append('max_age', options.maxAge.toString());
}

if (options && options.loginHint) {
url += '&login_hint=' + encodeURIComponent(options.loginHint);
if (options?.loginHint) {
params.append('login_hint', options.loginHint);
}

if (options && options.idpHint) {
url += '&kc_idp_hint=' + encodeURIComponent(options.idpHint);
if (options?.idpHint) {
params.append('kc_idp_hint', options.idpHint);
}

if (options && options.action && options.action !== 'register') {
url += '&kc_action=' + encodeURIComponent(options.action);
if (options?.action && options.action !== 'register') {
params.append('kc_action', options.action);
}

if (options && options.locale) {
url += '&ui_locales=' + encodeURIComponent(options.locale);
if (options?.locale) {
params.append('ui_locales', options.locale);
}

if (options && options.acr) {
var claimsParameter = buildClaimsParameter(options.acr);
url += '&claims=' + encodeURIComponent(claimsParameter);
if (options?.acr) {
params.append('claims', buildClaimsParameter(options.acr));
}

if ((options && options.acrValues) || kc.acrValues) {
url += '&acr_values=' + encodeURIComponent(options.acrValues || kc.acrValues);
if (options?.acrValues || kc.acrValues) {
params.append('acr_values', options.acrValues || kc.acrValues);
}

if (kc.pkceMethod) {
Expand All @@ -465,16 +469,16 @@ function Keycloak (config) {

callbackState.pkceCodeVerifier = codeVerifier;

url += '&code_challenge=' + pkceChallenge;
url += '&code_challenge_method=' + kc.pkceMethod;
params.append('code_challenge', pkceChallenge);
params.append('code_challenge_method', kc.pkceMethod);
} catch (error) {
throw new Error("Failed to generate PKCE challenge.", { cause: error });
}
}

callbackStorage.add(callbackState);

return url;
return `${baseUrl}?${params.toString()}`;
}

kc.logout = function(options) {
Expand All @@ -490,7 +494,7 @@ function Keycloak (config) {

var url = kc.endpoints.logout()
+ '?client_id=' + encodeURIComponent(kc.clientId)
+ '&post_logout_redirect_uri=' + encodeURIComponent(adapter.redirectUri(options, false));
+ '&post_logout_redirect_uri=' + encodeURIComponent(adapter.redirectUri(options));

if (kc.idToken) {
url += '&id_token_hint=' + encodeURIComponent(kc.idToken);
Expand Down Expand Up @@ -1310,7 +1314,7 @@ function Keycloak (config) {
const data = {
id_token_hint: kc.idToken,
client_id: kc.clientId,
post_logout_redirect_uri: adapter.redirectUri(options, false)
post_logout_redirect_uri: adapter.redirectUri(options)
};

for (const [name, value] of Object.entries(data)) {
Expand Down Expand Up @@ -1343,18 +1347,8 @@ function Keycloak (config) {
return createPromise().promise;
},

redirectUri: function(options, encodeHash) {
if (arguments.length === 1) {
encodeHash = true;
}

if (options && options.redirectUri) {
return options.redirectUri;
} else if (kc.redirectUri) {
return kc.redirectUri;
} else {
return location.href;
}
redirectUri: function(options) {
return options?.redirectUri || kc.redirectUri || location.href;
}
};
}
Expand Down
2 changes: 1 addition & 1 deletion test/support/test-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export class TestExecutor {
await this.#page.getByRole('button', { name: 'Sign In' }).click()
}

async createLoginUrl (options: KeycloakLoginOptions): Promise<string> {
async createLoginUrl (options?: KeycloakLoginOptions): Promise<string> {
await this.#assertInstantiated()
return await this.#page.evaluate(async (options) => {
return await ((globalThis as any).keycloak as Keycloak).createLoginUrl(options)
Expand Down
2 changes: 2 additions & 0 deletions test/support/testbed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const test = base.extend<TestOptions>({

export interface TestBed {
executor: TestExecutor
realm: string
updateRealm: (changes: RealmRepresentation) => Promise<void>
updateClient: (changes: ClientRepresentation) => Promise<void>
}
Expand Down Expand Up @@ -88,6 +89,7 @@ export async function createTestBed (page: Page, options: TestExecutorOptions):
})

return {
realm,
executor: new TestExecutor(page, realm, options),
async updateRealm (changes) {
const representation = await adminClient.realms.findOne({ realm })
Expand Down
44 changes: 0 additions & 44 deletions test/tests/claims.spec.ts

This file was deleted.

2 changes: 1 addition & 1 deletion test/tests/implicit-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import type { KeycloakInitOptions } from '../../lib/keycloak.js'
import type { KeycloakInitOptions } from '../../lib/keycloak.d.ts'
import { createTestBed, test } from '../support/testbed.ts'

test('logs in with an implicit flow', async ({ page, appUrl, authServerUrl }) => {
Expand Down
123 changes: 123 additions & 0 deletions test/tests/login-url.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { expect } from '@playwright/test'
import type { KeycloakInitOptions } from '../../lib/keycloak.d.ts'
import { AUTHORIZED_USERNAME, CLIENT_ID } from '../support/common.ts'
import { createTestBed, test } from '../support/testbed.ts'

test('creates a login URL with all options', async ({ page, appUrl, authServerUrl }) => {
const { executor, realm } = await createTestBed(page, { appUrl, authServerUrl })
await executor.initializeAdapter(executor.defaultInitOptions())
const redirectUri = new URL('/foo/bar', appUrl)
const loginUrl = new URL(await executor.createLoginUrl({
scope: 'openid profile email',
redirectUri: redirectUri.toString(),
prompt: 'none',
maxAge: 3600,
loginHint: AUTHORIZED_USERNAME,
idpHint: 'facebook',
action: 'UPDATE_PASSWORD',
locale: 'nl-NL nl',
acr: {
values: ['foo', 'bar'],
essential: false
},
acrValues: '2fa'
}))
expect(loginUrl.pathname).toBe(`/realms/${realm}/protocol/openid-connect/auth`)
expect(loginUrl.searchParams.get('client_id')).toBe(CLIENT_ID)
expect(loginUrl.searchParams.get('redirect_uri')).toBe(redirectUri.toString())
expect(loginUrl.searchParams.get('state')).toEqual(expect.any(String))
expect(loginUrl.searchParams.get('response_mode')).toBe('fragment')
expect(loginUrl.searchParams.get('response_type')).toBe('code')
expect(loginUrl.searchParams.get('scope')).toBe('openid profile email')
expect(loginUrl.searchParams.get('nonce')).toEqual(expect.any(String))
expect(loginUrl.searchParams.get('prompt')).toBe('none')
expect(loginUrl.searchParams.get('max_age')).toBe('3600')
expect(loginUrl.searchParams.get('login_hint')).toBe(AUTHORIZED_USERNAME)
expect(loginUrl.searchParams.get('kc_idp_hint')).toBe('facebook')
expect(loginUrl.searchParams.get('kc_action')).toBe('UPDATE_PASSWORD')
expect(loginUrl.searchParams.get('ui_locales')).toBe('nl-NL nl')
expect(loginUrl.searchParams.get('claims')).toBe('{"id_token":{"acr":{"values":["foo","bar"],"essential":false}}}')
expect(loginUrl.searchParams.get('acr_values')).toBe('2fa')
expect(loginUrl.searchParams.get('code_challenge')).toEqual(expect.any(String))
expect(loginUrl.searchParams.get('code_challenge_method')).toBe('S256')
})

test('creates a login URL with default options', async ({ page, appUrl, authServerUrl }) => {
const { executor, realm } = await createTestBed(page, { appUrl, authServerUrl })
await executor.initializeAdapter(executor.defaultInitOptions())
const loginUrl = new URL(await executor.createLoginUrl())
expect(loginUrl.pathname).toBe(`/realms/${realm}/protocol/openid-connect/auth`)
expect(loginUrl.searchParams.get('client_id')).toBe(CLIENT_ID)
expect(loginUrl.searchParams.get('redirect_uri')).toBe(appUrl.toString())
expect(loginUrl.searchParams.get('state')).toEqual(expect.any(String))
expect(loginUrl.searchParams.get('response_mode')).toBe('fragment')
expect(loginUrl.searchParams.get('response_type')).toBe('code')
expect(loginUrl.searchParams.get('scope')).toBe('openid')
expect(loginUrl.searchParams.get('nonce')).toEqual(expect.any(String))
expect(loginUrl.searchParams.get('prompt')).toBeNull()
expect(loginUrl.searchParams.get('max_age')).toBeNull()
expect(loginUrl.searchParams.get('login_hint')).toBeNull()
expect(loginUrl.searchParams.get('kc_idp_hint')).toBeNull()
expect(loginUrl.searchParams.get('kc_action')).toBeNull()
expect(loginUrl.searchParams.get('ui_locales')).toBeNull()
expect(loginUrl.searchParams.get('claims')).toBeNull()
expect(loginUrl.searchParams.get('acr_values')).toBeNull()
expect(loginUrl.searchParams.get('code_challenge')).toEqual(expect.any(String))
expect(loginUrl.searchParams.get('code_challenge_method')).toBe('S256')
})

test('creates a login URL to the registration page', async ({ page, appUrl, authServerUrl }) => {
const { executor, realm } = await createTestBed(page, { appUrl, authServerUrl })
await executor.initializeAdapter(executor.defaultInitOptions())
const loginUrl = new URL(await executor.createLoginUrl({ action: 'register' }))
expect(loginUrl.pathname).toBe(`/realms/${realm}/protocol/openid-connect/registrations`)
expect(loginUrl.searchParams.get('kc_action')).toBeNull()
})

test('creates a login URL using the redirect URL passed during initialization', async ({ page, appUrl, authServerUrl }) => {
const { executor } = await createTestBed(page, { appUrl, authServerUrl })
const redirectUri = new URL('/foo/bar', appUrl)
const initOptions: KeycloakInitOptions = { ...executor.defaultInitOptions(), redirectUri: redirectUri.toString() }
await executor.initializeAdapter(initOptions)
const loginUrl = new URL(await executor.createLoginUrl())
expect(loginUrl.searchParams.get('redirect_uri')).toBe(redirectUri.toString())
})

test('creates a login URL using the scope passed during initialization', async ({ page, appUrl, authServerUrl }) => {
const { executor } = await createTestBed(page, { appUrl, authServerUrl })
const initOptions: KeycloakInitOptions = { ...executor.defaultInitOptions(), scope: 'openid profile email' }
await executor.initializeAdapter(initOptions)
const loginUrl = new URL(await executor.createLoginUrl())
expect(loginUrl.searchParams.get('scope')).toBe('openid profile email')
})

test("creates a login URL with the 'openid' scope appended if omitted", async ({ page, appUrl, authServerUrl }) => {
const { executor } = await createTestBed(page, { appUrl, authServerUrl })
const initOptions: KeycloakInitOptions = { ...executor.defaultInitOptions(), scope: 'profile email' }
await executor.initializeAdapter(initOptions)
const loginUrl = new URL(await executor.createLoginUrl())
expect(loginUrl.searchParams.get('scope')).toBe('openid profile email')
})

test('creates a login URL using the response mode passed during initialization', async ({ page, appUrl, authServerUrl }) => {
const { executor } = await createTestBed(page, { appUrl, authServerUrl })
const initOptions: KeycloakInitOptions = { ...executor.defaultInitOptions(), responseMode: 'query' }
await executor.initializeAdapter(initOptions)
const loginUrl = new URL(await executor.createLoginUrl())
expect(loginUrl.searchParams.get('response_mode')).toBe('query')
})

test('creates a login URL based on the flow passed during initialization', async ({ page, appUrl, authServerUrl }) => {
const { executor } = await createTestBed(page, { appUrl, authServerUrl })
const initOptions: KeycloakInitOptions = { ...executor.defaultInitOptions(), flow: 'implicit' }
await executor.initializeAdapter(initOptions)
const loginUrl = new URL(await executor.createLoginUrl())
expect(loginUrl.searchParams.get('response_type')).toBe('id_token token')
})

test('creates a login URL with a max age of 0', async ({ page, appUrl, authServerUrl }) => {
const { executor } = await createTestBed(page, { appUrl, authServerUrl })
await executor.initializeAdapter(executor.defaultInitOptions())
const loginUrl = new URL(await executor.createLoginUrl({ maxAge: 0 }))
expect(loginUrl.searchParams.get('max_age')).toBe('0')
})
2 changes: 1 addition & 1 deletion test/tests/login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import type { KeycloakInitOptions } from '../../lib/keycloak.js'
import type { KeycloakInitOptions } from '../../lib/keycloak.d.ts'
import { createTestBed, test } from '../support/testbed.ts'

test('logs in and out', async ({ page, appUrl, authServerUrl }) => {
Expand Down
Loading
点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载