mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
feat: sign out of the identity provider too when signing out of a browser sign-in
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Live check of ending the provider's session at sign-out (OIDC
|
||||||
|
* RP-Initiated Logout) against a real Stalwart. Opt-in like the other live
|
||||||
|
* tests:
|
||||||
|
*
|
||||||
|
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||||
|
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
|
||||||
|
* npx vitest run src/api/__tests__/live-stalwart-end-session.test.ts
|
||||||
|
*
|
||||||
|
* 1.0 announces an end_session_endpoint and hands out an id token with the
|
||||||
|
* tokens; 0.16 has neither, and sign-out opens nothing there. The grant the
|
||||||
|
* run makes is revoked again.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('expo-secure-store', () => ({
|
||||||
|
setItemAsync: vi.fn(async () => undefined),
|
||||||
|
getItemAsync: vi.fn(async () => null),
|
||||||
|
deleteItemAsync: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { buildEndSessionUrl, discoverOAuthMetadata, revokeRefreshToken } from '../../lib/oauth-native';
|
||||||
|
import { exchangePasswordForTokens } from '../../lib/totp-login';
|
||||||
|
|
||||||
|
const SERVER = (process.env.BULWARK_LIVE_JMAP ?? '').replace(/\/+$/, '');
|
||||||
|
const USER = process.env.BULWARK_LIVE_USER ?? '';
|
||||||
|
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||||
|
const live = SERVER && USER && PASS ? describe : describe.skip;
|
||||||
|
|
||||||
|
live('ending the provider session at sign-out (live)', () => {
|
||||||
|
it('logs out with the id token where the server announces an end_session_endpoint', async () => {
|
||||||
|
const metadata = await discoverOAuthMetadata(SERVER);
|
||||||
|
expect(metadata).not.toBeNull();
|
||||||
|
const tokens = await exchangePasswordForTokens(SERVER, USER, PASS);
|
||||||
|
try {
|
||||||
|
if (!metadata!.end_session_endpoint) {
|
||||||
|
// 0.16: nothing to open.
|
||||||
|
expect(buildEndSessionUrl(SERVER, metadata!, tokens)).toBeNull();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 1.0: the token answer carries an id token (a JWT), kept for the hint.
|
||||||
|
expect(tokens.idToken?.split('.')).toHaveLength(3);
|
||||||
|
const target = buildEndSessionUrl(SERVER, metadata!, tokens);
|
||||||
|
expect(target).not.toBeNull();
|
||||||
|
const url = new URL(target!.url);
|
||||||
|
expect(url.searchParams.get('id_token_hint')).toBe(tokens.idToken);
|
||||||
|
const response = await fetch(target!.url, { redirect: 'manual' });
|
||||||
|
// Signed out: a page saying so, or back to where the app asked.
|
||||||
|
expect([200, 302, 303]).toContain(response.status);
|
||||||
|
if (response.status !== 200) {
|
||||||
|
expect(response.headers.get('location')).toBe(url.searchParams.get('post_logout_redirect_uri'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await revokeRefreshToken(SERVER, tokens);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,9 @@ export interface StoredCredentials {
|
|||||||
tokenEndpoint?: string;
|
tokenEndpoint?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
tokenSource?: OAuthTokenSource;
|
tokenSource?: OAuthTokenSource;
|
||||||
|
// The OIDC id token of a browser sign-in, for ending the provider's
|
||||||
|
// session at sign-out (lib/oauth-native endProviderSession).
|
||||||
|
idToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -404,6 +407,7 @@ export class JMAPClient {
|
|||||||
tokenEndpoint: tokens.tokenEndpoint,
|
tokenEndpoint: tokens.tokenEndpoint,
|
||||||
clientId: tokens.clientId,
|
clientId: tokens.clientId,
|
||||||
tokenSource: tokens.source,
|
tokenSource: tokens.source,
|
||||||
|
...(tokens.idToken ? { idToken: tokens.idToken } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetched = await this.fetchSession(baseUrl);
|
const fetched = await this.fetchSession(baseUrl);
|
||||||
@@ -460,6 +464,7 @@ export class JMAPClient {
|
|||||||
expiresAt: next.expiresAt,
|
expiresAt: next.expiresAt,
|
||||||
tokenEndpoint: next.tokenEndpoint,
|
tokenEndpoint: next.tokenEndpoint,
|
||||||
clientId: next.clientId,
|
clientId: next.clientId,
|
||||||
|
idToken: next.idToken ?? base.idToken,
|
||||||
};
|
};
|
||||||
if (inUse) this.credentials = updated;
|
if (inUse) this.credentials = updated;
|
||||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(updated));
|
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(updated));
|
||||||
@@ -529,6 +534,7 @@ export class JMAPClient {
|
|||||||
tokenEndpoint: creds.tokenEndpoint,
|
tokenEndpoint: creds.tokenEndpoint,
|
||||||
clientId: creds.clientId,
|
clientId: creds.clientId,
|
||||||
source: creds.tokenSource,
|
source: creds.tokenSource,
|
||||||
|
...(creds.idToken ? { idToken: creds.idToken } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
|
||||||
|
// Sign-out ends the identity provider's own session too (OIDC RP-Initiated
|
||||||
|
// Logout, webmail #905): revoking the refresh token left the browser signed
|
||||||
|
// in at the provider, and the next browser sign-in went through unasked.
|
||||||
|
|
||||||
|
vi.mock('../client-cert', () => {
|
||||||
|
const fetchFn = vi.fn();
|
||||||
|
return {
|
||||||
|
secureFetch: fetchFn,
|
||||||
|
fetchWithoutForeignRedirect: fetchFn,
|
||||||
|
followedOnlyAllowedRedirects: () => true,
|
||||||
|
mayFollowRedirect: () => true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { fetchWithoutForeignRedirect } from '../client-cert';
|
||||||
|
import { buildEndSessionUrl, endProviderSession, loginWithPkce, type OAuthMetadata } from '../oauth-native';
|
||||||
|
import { refreshOAuthAccessToken, resetRefreshRotations, type OAuthTokens } from '../oauth';
|
||||||
|
import { DEFAULT_CLIENT_ID, MOBILE_CLIENT_ID, REGISTERED_LOGOUT_REDIRECT_URI } from '../totp-login';
|
||||||
|
|
||||||
|
const fetchMock = fetchWithoutForeignRedirect as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const browser = WebBrowser.openAuthSessionAsync as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const SERVER = 'https://mail.example.org';
|
||||||
|
const DISCOVERY = {
|
||||||
|
issuer: SERVER,
|
||||||
|
authorization_endpoint: `${SERVER}/login`,
|
||||||
|
token_endpoint: `${SERVER}/auth/token`,
|
||||||
|
end_session_endpoint: `${SERVER}/auth/logout`,
|
||||||
|
};
|
||||||
|
const json = (body: unknown) => ({ ok: true, status: 200, url: '', json: async () => body, text: async () => JSON.stringify(body) });
|
||||||
|
|
||||||
|
function tokens(over: Partial<OAuthTokens> = {}): OAuthTokens {
|
||||||
|
return {
|
||||||
|
accessToken: 'at', refreshToken: 'rt', tokenEndpoint: `${SERVER}/auth/token`,
|
||||||
|
clientId: 'registered-client', source: 'native', idToken: 'id.token.sig', ...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetRefreshRotations();
|
||||||
|
browser.mockResolvedValue({ type: 'dismiss' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildEndSessionUrl', () => {
|
||||||
|
it('names the client, hints the id token and comes back to the app for a client the app registered', () => {
|
||||||
|
const target = buildEndSessionUrl(SERVER, DISCOVERY, tokens());
|
||||||
|
const url = new URL(target!.url);
|
||||||
|
expect(url.origin + url.pathname).toBe(`${SERVER}/auth/logout`);
|
||||||
|
expect(url.searchParams.get('client_id')).toBe('registered-client');
|
||||||
|
expect(url.searchParams.get('id_token_hint')).toBe('id.token.sig');
|
||||||
|
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(REGISTERED_LOGOUT_REDIRECT_URI);
|
||||||
|
expect(target!.returnUrl).toBe(REGISTERED_LOGOUT_REDIRECT_URI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no return address for a client someone else registered', () => {
|
||||||
|
for (const clientId of [DEFAULT_CLIENT_ID, MOBILE_CLIENT_ID]) {
|
||||||
|
const url = new URL(buildEndSessionUrl(SERVER, DISCOVERY, tokens({ clientId }))!.url);
|
||||||
|
expect(url.searchParams.get('post_logout_redirect_uri')).toBeNull();
|
||||||
|
expect(url.searchParams.get('client_id')).toBe(clientId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a provider without an end_session_endpoint, or one on another host in the clear', () => {
|
||||||
|
expect(buildEndSessionUrl(SERVER, { end_session_endpoint: undefined }, tokens())).toBeNull();
|
||||||
|
expect(buildEndSessionUrl(SERVER, { end_session_endpoint: 'http://idp.example.net/logout' }, tokens())).toBeNull();
|
||||||
|
// The server's own host over http is what the user signed in to.
|
||||||
|
expect(buildEndSessionUrl('http://10.10.10.62:8120', { end_session_endpoint: 'http://10.10.10.62:8120/auth/logout' }, tokens()))
|
||||||
|
.not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('endProviderSession', () => {
|
||||||
|
it("opens the provider's logout page after a browser sign-in", async () => {
|
||||||
|
fetchMock.mockResolvedValue(json(DISCOVERY));
|
||||||
|
await endProviderSession(SERVER, tokens());
|
||||||
|
expect(browser).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, returnUrl] = browser.mock.calls[0] as [string, string];
|
||||||
|
expect(new URL(url).searchParams.get('id_token_hint')).toBe('id.token.sig');
|
||||||
|
expect(returnUrl).toBe(REGISTERED_LOGOUT_REDIRECT_URI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens nothing for sign-ins that left no browser session, or a provider without logout', async () => {
|
||||||
|
fetchMock.mockResolvedValue(json(DISCOVERY));
|
||||||
|
await endProviderSession(SERVER, tokens({ source: 'totp' }));
|
||||||
|
await endProviderSession(SERVER, tokens({ source: 'pairing' }));
|
||||||
|
fetchMock.mockResolvedValue(json({ ...DISCOVERY, end_session_endpoint: undefined }));
|
||||||
|
await endProviderSession(SERVER, tokens());
|
||||||
|
expect(browser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never fails the sign-out', async () => {
|
||||||
|
fetchMock.mockRejectedValue(new Error('offline'));
|
||||||
|
await expect(endProviderSession(SERVER, tokens())).resolves.toBeUndefined();
|
||||||
|
fetchMock.mockResolvedValue(json(DISCOVERY));
|
||||||
|
browser.mockRejectedValueOnce(new Error('another session is open'));
|
||||||
|
await expect(endProviderSession(SERVER, tokens())).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the id token', () => {
|
||||||
|
it('is kept from the browser sign-in', async () => {
|
||||||
|
const meta: OAuthMetadata = { authorization_endpoint: `${SERVER}/authorize`, token_endpoint: `${SERVER}/auth/token` };
|
||||||
|
browser.mockImplementationOnce(async (authUrl: string) => {
|
||||||
|
const state = new URL(authUrl).searchParams.get('state');
|
||||||
|
return { type: 'success', url: `bulwarkmobile://auth/callback?code=c&state=${state}` };
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(json({ access_token: 'at', refresh_token: 'rt', expires_in: 300, id_token: 'id.token.sig' }));
|
||||||
|
const got = await loginWithPkce(SERVER, meta, { clientId: 'c1' });
|
||||||
|
expect(got.idToken).toBe('id.token.sig');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outlives a refresh that hands out none', async () => {
|
||||||
|
fetchMock.mockResolvedValue(json({ access_token: 'at2', refresh_token: 'rt2', expires_in: 300 }));
|
||||||
|
const next = await refreshOAuthAccessToken(tokens());
|
||||||
|
expect(next.idToken).toBe('id.token.sig');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
generateCodeVerifier,
|
generateCodeVerifier,
|
||||||
resolveLoginClient,
|
resolveLoginClient,
|
||||||
DEFAULT_CLIENT_ID,
|
DEFAULT_CLIENT_ID,
|
||||||
|
MOBILE_CLIENT_ID,
|
||||||
|
REGISTERED_LOGOUT_REDIRECT_URI,
|
||||||
|
REGISTERED_REDIRECT_URI,
|
||||||
} from './totp-login';
|
} from './totp-login';
|
||||||
import { randomHex } from './random';
|
import { randomHex } from './random';
|
||||||
import { NetworkError } from '../api/jmap-client';
|
import { NetworkError } from '../api/jmap-client';
|
||||||
@@ -27,6 +30,7 @@ export interface OAuthMetadata {
|
|||||||
authorization_endpoint: string;
|
authorization_endpoint: string;
|
||||||
token_endpoint: string;
|
token_endpoint: string;
|
||||||
revocation_endpoint?: string;
|
revocation_endpoint?: string;
|
||||||
|
end_session_endpoint?: string;
|
||||||
registration_endpoint?: string;
|
registration_endpoint?: string;
|
||||||
scopes_supported?: string[];
|
scopes_supported?: string[];
|
||||||
prompt_values_supported?: string[];
|
prompt_values_supported?: string[];
|
||||||
@@ -264,6 +268,7 @@ export async function loginWithPkce(
|
|||||||
access_token?: string;
|
access_token?: string;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
|
id_token?: string;
|
||||||
};
|
};
|
||||||
if (!data.access_token) throw new HandoffError('Token response missing access_token');
|
if (!data.access_token) throw new HandoffError('Token response missing access_token');
|
||||||
return {
|
return {
|
||||||
@@ -273,9 +278,70 @@ export async function loginWithPkce(
|
|||||||
tokenEndpoint: metadata.token_endpoint,
|
tokenEndpoint: metadata.token_endpoint,
|
||||||
clientId,
|
clientId,
|
||||||
source: 'native',
|
source: 'native',
|
||||||
|
// Kept for ending the provider's session at sign-out (endProviderSession).
|
||||||
|
...(typeof data.id_token === 'string' && data.id_token ? { idToken: data.id_token } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider's logout URL for a sign-out (OIDC RP-Initiated Logout 1.0;
|
||||||
|
* webmail lib/oauth/end-session.ts buildEndSessionUrl), or null when the
|
||||||
|
* provider advertises no end_session_endpoint or an unusable one.
|
||||||
|
*
|
||||||
|
* `client_id` always goes along, `id_token_hint` when the sign-in's id token
|
||||||
|
* was kept. The browser carries the id token there, so the endpoint must be
|
||||||
|
* https, or on the mail server's own host, which the app already talks to in
|
||||||
|
* the clear when the user chose that. `post_logout_redirect_uri` is sent only
|
||||||
|
* for a client the app registered itself, with that URI on file: a provider
|
||||||
|
* refuses the whole logout for one it does not have.
|
||||||
|
*/
|
||||||
|
export function buildEndSessionUrl(
|
||||||
|
serverUrl: string,
|
||||||
|
metadata: Pick<OAuthMetadata, 'end_session_endpoint'>,
|
||||||
|
tokens: Pick<OAuthTokens, 'clientId' | 'idToken'>,
|
||||||
|
): { url: string; returnUrl: string } | null {
|
||||||
|
const endpoint = metadata.end_session_endpoint;
|
||||||
|
if (typeof endpoint !== 'string' || !/^https?:\/\//i.test(endpoint)) return null;
|
||||||
|
if (!/^https:\/\//i.test(endpoint) && !sameHost(endpoint, serverUrl)) return null;
|
||||||
|
const registered = tokens.clientId !== DEFAULT_CLIENT_ID && tokens.clientId !== MOBILE_CLIENT_ID;
|
||||||
|
const params = new URLSearchParams({ client_id: tokens.clientId });
|
||||||
|
if (tokens.idToken) params.set('id_token_hint', tokens.idToken);
|
||||||
|
if (registered) params.set('post_logout_redirect_uri', REGISTERED_LOGOUT_REDIRECT_URI);
|
||||||
|
return {
|
||||||
|
url: `${endpoint}${endpoint.includes('?') ? '&' : '?'}${params.toString()}`,
|
||||||
|
// The browser session closes when the provider sends it back to the app.
|
||||||
|
returnUrl: registered ? REGISTERED_LOGOUT_REDIRECT_URI : REGISTERED_REDIRECT_URI,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let endSessionQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the identity provider's own login session at sign-out, best effort.
|
||||||
|
* Revoking the refresh token ends the app's grant, but the session the
|
||||||
|
* provider keeps in the browser stays, and the next browser sign-in would go
|
||||||
|
* through without asking (webmail #905). Only a browser sign-in left one
|
||||||
|
* (source 'native'); the password and TOTP sign-ins and the webmail's
|
||||||
|
* hand-off and pairing did not, or own it. Opens the logout page in the
|
||||||
|
* auth browser session, one at a time. Never throws and is not awaited by
|
||||||
|
* the sign-out.
|
||||||
|
*/
|
||||||
|
export function endProviderSession(serverUrl: string, tokens: OAuthTokens): Promise<void> {
|
||||||
|
if (tokens.source !== 'native') return Promise.resolve();
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const metadata = await discoverOAuthMetadata(serverUrl);
|
||||||
|
const target = metadata ? buildEndSessionUrl(serverUrl, metadata, tokens) : null;
|
||||||
|
if (!target) return;
|
||||||
|
await WebBrowser.openAuthSessionAsync(target.url, target.returnUrl);
|
||||||
|
} catch {
|
||||||
|
// never block or fail a sign-out
|
||||||
|
}
|
||||||
|
};
|
||||||
|
endSessionQueue = endSessionQueue.then(run, run);
|
||||||
|
return endSessionQueue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort RFC 7009 revocation of a refresh token on sign-out. Never
|
* Best-effort RFC 7009 revocation of a refresh token on sign-out. Never
|
||||||
* throws; the caller is already dropping the credentials.
|
* throws; the caller is already dropping the credentials.
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface OAuthTokens {
|
|||||||
// the desktop's token, so sign-out leaves `pairing` bundles alone instead
|
// the desktop's token, so sign-out leaves `pairing` bundles alone instead
|
||||||
// of revoking them at the mail server; every other source is revoked.
|
// of revoking them at the mail server; every other source is revoked.
|
||||||
source?: OAuthTokenSource;
|
source?: OAuthTokenSource;
|
||||||
|
// The OIDC id token of the sign-in, kept for ending the provider's
|
||||||
|
// session at sign-out (id_token_hint). A refresh may hand out a new one.
|
||||||
|
idToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HandoffResult =
|
export type HandoffResult =
|
||||||
@@ -681,6 +684,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
|||||||
access_token?: string;
|
access_token?: string;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
|
id_token?: string;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
data = (await response.json()) as typeof data;
|
data = (await response.json()) as typeof data;
|
||||||
@@ -702,6 +706,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
|||||||
tokenEndpoint: tokens.tokenEndpoint,
|
tokenEndpoint: tokens.tokenEndpoint,
|
||||||
clientId: tokens.clientId,
|
clientId: tokens.clientId,
|
||||||
source: tokens.source,
|
source: tokens.source,
|
||||||
|
idToken: typeof data.id_token === 'string' && data.id_token ? data.id_token : tokens.idToken,
|
||||||
};
|
};
|
||||||
rememberRotation(cacheKey, next);
|
rememberRotation(cacheKey, next);
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ export const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
|||||||
*/
|
*/
|
||||||
export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
|
export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the identity provider sends the browser back after sign-out ended
|
||||||
|
* its session (OIDC RP-Initiated Logout), registered with the client the app
|
||||||
|
* registers. Same scheme as the sign-in redirect, which the app handles.
|
||||||
|
*/
|
||||||
|
export const REGISTERED_LOGOUT_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/logout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's own client, which the webmail's admin OAuth setup registers on
|
* The app's own client, which the webmail's admin OAuth setup registers on
|
||||||
* Stalwart 1.0 (webmail #1459): public and first-party, so its tokens reach
|
* Stalwart 1.0 (webmail #1459): public and first-party, so its tokens reach
|
||||||
@@ -174,6 +181,7 @@ async function registerPublicClient(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_name: 'Bulwark Mobile',
|
client_name: 'Bulwark Mobile',
|
||||||
redirect_uris: [REGISTERED_REDIRECT_URI],
|
redirect_uris: [REGISTERED_REDIRECT_URI],
|
||||||
|
post_logout_redirect_uris: [REGISTERED_LOGOUT_REDIRECT_URI],
|
||||||
token_endpoint_auth_method: 'none',
|
token_endpoint_auth_method: 'none',
|
||||||
grant_types: ['authorization_code', 'refresh_token'],
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
response_types: ['code'],
|
response_types: ['code'],
|
||||||
@@ -552,6 +560,7 @@ export async function exchangePasswordForTokens(
|
|||||||
access_token?: string;
|
access_token?: string;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
|
id_token?: string;
|
||||||
};
|
};
|
||||||
if (!tokens.access_token) {
|
if (!tokens.access_token) {
|
||||||
throw new TotpLoginError('token_exchange_failed', 'Token response missing access_token');
|
throw new TotpLoginError('token_exchange_failed', 'Token response missing access_token');
|
||||||
@@ -564,5 +573,6 @@ export async function exchangePasswordForTokens(
|
|||||||
tokenEndpoint: tokenUrl,
|
tokenEndpoint: tokenUrl,
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
source: 'totp',
|
source: 'totp',
|
||||||
|
...(typeof tokens.id_token === 'string' && tokens.id_token ? { idToken: tokens.id_token } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
forgetRefreshRotations,
|
forgetRefreshRotations,
|
||||||
type HandoffResult,
|
type HandoffResult,
|
||||||
} from '../lib/oauth';
|
} from '../lib/oauth';
|
||||||
import { discoverOAuthMetadata, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
|
import { discoverOAuthMetadata, endProviderSession, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
|
||||||
import {
|
import {
|
||||||
teardownPushNotifications,
|
teardownPushNotifications,
|
||||||
teardownPushNotificationsForAccount,
|
teardownPushNotificationsForAccount,
|
||||||
@@ -501,7 +501,9 @@ function refuseAddWhenFull(set: (partial: Partial<AuthState>) => void, opts?: Si
|
|||||||
// desktop's own refresh token, and newer ones hand out a separate grant whose
|
// desktop's own refresh token, and newer ones hand out a separate grant whose
|
||||||
// refresh token only the webmail's token proxy understands. The account's
|
// refresh token only the webmail's token proxy understands. The account's
|
||||||
// chain also leaves the in-process rotation memory either way, so a stale copy
|
// chain also leaves the in-process rotation memory either way, so a stale copy
|
||||||
// refreshed after the sign-out can't pick up its tokens from there.
|
// refreshed after the sign-out can't pick up its tokens from there. A browser
|
||||||
|
// sign-in's provider session is ended too, in the background: the sign-out
|
||||||
|
// never waits for the logout page (see endProviderSession).
|
||||||
async function revokeStoredRefreshToken(accountId: string): Promise<void> {
|
async function revokeStoredRefreshToken(accountId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const entry = useAccountStore.getState().getAccountById(accountId);
|
const entry = useAccountStore.getState().getAccountById(accountId);
|
||||||
@@ -509,6 +511,7 @@ async function revokeStoredRefreshToken(accountId: string): Promise<void> {
|
|||||||
forgetRefreshRotations(tokens?.refreshToken);
|
forgetRefreshRotations(tokens?.refreshToken);
|
||||||
if (!entry || !tokens || tokens.source === 'pairing') return;
|
if (!entry || !tokens || tokens.source === 'pairing') return;
|
||||||
await revokeRefreshToken(entry.serverUrl, tokens);
|
await revokeRefreshToken(entry.serverUrl, tokens);
|
||||||
|
void endProviderSession(entry.serverUrl, tokens);
|
||||||
} catch {
|
} catch {
|
||||||
// never block sign-out
|
// never block sign-out
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user