mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: sign in with an access token
This commit is contained in:
@@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({
|
||||
}));
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { JMAPClient, AuthenticationError, RateLimitError, parseHoldLimit } from '../jmap-client';
|
||||
import { JMAPClient, AuthenticationError, InvalidTokenError, RateLimitError, parseHoldLimit } from '../jmap-client';
|
||||
import type { JMAPSession } from '../types';
|
||||
|
||||
const MOCK_SESSION: JMAPSession = {
|
||||
@@ -113,8 +113,10 @@ describe('JMAPClient', () => {
|
||||
});
|
||||
|
||||
describe('connectWithToken', () => {
|
||||
const TOKEN_SESSION = { ...MOCK_SESSION, username: 'user@example.com' };
|
||||
|
||||
it('should use Bearer auth', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
||||
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||
|
||||
await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||
|
||||
@@ -128,6 +130,70 @@ describe('JMAPClient', () => {
|
||||
);
|
||||
expect(client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('names the account after the session and remembers the token', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||
|
||||
const { username, accountId } = await client.connectWithToken('https://mail.example.com/', 'my-token');
|
||||
|
||||
expect(username).toBe('user@example.com');
|
||||
expect(accountId).toBe('user@example.com@mail.example.com');
|
||||
const [key, stored] = vi.mocked(SecureStore.setItemAsync).mock.calls.at(-1)!;
|
||||
expect(key).toContain('user_example.com');
|
||||
expect(JSON.parse(stored)).toMatchObject({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'user@example.com',
|
||||
accessToken: 'my-token',
|
||||
});
|
||||
expect(client.usesPastedToken).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a token the server rejects', async () => {
|
||||
global.fetch = mockFetch([{ status: 401 }]) as any;
|
||||
|
||||
await expect(client.connectWithToken('https://mail.example.com', 'bad')).rejects.toThrow(InvalidTokenError);
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a session that does not name the account', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
||||
|
||||
await expect(client.connectWithToken('https://mail.example.com', 'my-token')).rejects.toThrow('did not name the account');
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a download host the server runs on another HTTPS origin', async () => {
|
||||
global.fetch = mockFetch([{
|
||||
status: 200,
|
||||
json: {
|
||||
...TOKEN_SESSION,
|
||||
apiUrl: 'https://api.fastmail.com/jmap/api/',
|
||||
downloadUrl: 'https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
uploadUrl: 'https://api.fastmail.com/jmap/upload/{accountId}/',
|
||||
},
|
||||
}]) as any;
|
||||
|
||||
const { session } = await client.connectWithToken('https://api.fastmail.com', 'my-token');
|
||||
|
||||
expect(session.downloadUrl).toBe('https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}');
|
||||
expect(session.uploadUrl).toBe('https://api.fastmail.com/jmap/upload/{accountId}/');
|
||||
});
|
||||
|
||||
it('still moves self-reported internal hosts onto the server address', async () => {
|
||||
global.fetch = mockFetch([{
|
||||
status: 200,
|
||||
json: {
|
||||
...TOKEN_SESSION,
|
||||
apiUrl: 'http://localhost:8080/jmap/',
|
||||
downloadUrl: 'http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
},
|
||||
}]) as any;
|
||||
|
||||
const { session } = await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||
|
||||
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
|
||||
expect(session.downloadUrl).toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request', () => {
|
||||
|
||||
+57
-6
@@ -177,6 +177,12 @@ export class JMAPClient {
|
||||
return !!this.credentials?.accessToken;
|
||||
}
|
||||
|
||||
// A pasted access token, as opposed to an OAuth bundle (which always names
|
||||
// the token endpoint it renews at).
|
||||
get usesPastedToken(): boolean {
|
||||
return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.session !== null && this._accountId !== null;
|
||||
}
|
||||
@@ -294,15 +300,42 @@ export class JMAPClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
|
||||
/**
|
||||
* Access-token login (an API token the user pasted, e.g. from Fastmail).
|
||||
* The token is the whole credential: it names the account, so the username
|
||||
* comes from the session, and with no refresh token it lasts until the
|
||||
* provider expires or revokes it.
|
||||
*/
|
||||
async connectWithToken(
|
||||
serverUrl: string,
|
||||
accessToken: string,
|
||||
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
|
||||
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
try {
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
} catch (err) {
|
||||
this.credentials = null;
|
||||
if (err instanceof AuthenticationError || (err instanceof Error && /^Session discovery failed: 403\b/.test(err.message))) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
return this.session;
|
||||
const username = this.session.username;
|
||||
if (!username) {
|
||||
this.reset();
|
||||
throw new Error('The server did not name the account');
|
||||
}
|
||||
this.credentials.username = username;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
|
||||
return { session: this.session, username, accountId };
|
||||
}
|
||||
|
||||
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
||||
@@ -612,16 +645,26 @@ export class JMAPClient {
|
||||
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
||||
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
||||
// before the caller has a chance to substitute values into them.
|
||||
//
|
||||
// A URL on another HTTPS origin than the server's own apiUrl is one it
|
||||
// deliberately hosts elsewhere (Fastmail serves downloads from a separate
|
||||
// domain) and is kept; everything else moves onto the origin we connected to.
|
||||
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
||||
const serverOrigin = extractOrigin(serverUrl);
|
||||
const reportedOrigin = session.apiUrl ? extractOrigin(session.apiUrl) : null;
|
||||
const rewrite = (url: string | undefined): string | undefined =>
|
||||
rewriteSessionUrl(url, serverOrigin);
|
||||
const rewriteHosted = (url: string | undefined): string | undefined => {
|
||||
const origin = url ? extractOrigin(url) : null;
|
||||
const hostedElsewhere = !!origin && !!reportedOrigin && origin !== reportedOrigin && /^https:/i.test(origin);
|
||||
return hostedElsewhere ? url : rewrite(url);
|
||||
};
|
||||
return {
|
||||
...session,
|
||||
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
|
||||
downloadUrl: rewrite(session.downloadUrl) ?? session.downloadUrl,
|
||||
uploadUrl: rewrite(session.uploadUrl) ?? session.uploadUrl,
|
||||
eventSourceUrl: rewrite(session.eventSourceUrl) ?? session.eventSourceUrl,
|
||||
downloadUrl: rewriteHosted(session.downloadUrl) ?? session.downloadUrl,
|
||||
uploadUrl: rewriteHosted(session.uploadUrl) ?? session.uploadUrl,
|
||||
eventSourceUrl: rewriteHosted(session.eventSourceUrl) ?? session.eventSourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1283,6 +1326,14 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The server refused a pasted access token (wrong, expired or revoked).
|
||||
export class InvalidTokenError extends Error {
|
||||
constructor() {
|
||||
super('Invalid access token');
|
||||
this.name = 'InvalidTokenError';
|
||||
}
|
||||
}
|
||||
|
||||
// The server wants a second factor before it will hand out a session.
|
||||
export class TotpRequiredError extends AuthenticationError {
|
||||
constructor() {
|
||||
|
||||
Reference in New Issue
Block a user