feat(core): improve auth handling (#15271)
fix #15270 fix #15260 fix #15257 #### PR Dependency Tree * **PR #15271** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
@@ -1612,7 +1612,7 @@
|
|||||||
},
|
},
|
||||||
"providers.oidc": {
|
"providers.oidc": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "OIDC OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"args\":{}}\n@link https://openid.net/specs/openid-connect-core-1_0.html",
|
"description": "OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"allowPrivateNetwork\":false,\"args\":{}}\n@link https://openid.net/specs/openid-connect-core-1_0.html",
|
||||||
"properties": {
|
"properties": {
|
||||||
"clientId": {
|
"clientId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1622,12 +1622,21 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "OIDC issuer URL"
|
||||||
|
},
|
||||||
|
"allowPrivateNetwork": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Allow the OIDC issuer origin to resolve to private network addresses"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"default": {
|
"default": {
|
||||||
"clientId": "",
|
"clientId": "",
|
||||||
"clientSecret": "",
|
"clientSecret": "",
|
||||||
"issuer": "",
|
"issuer": "",
|
||||||
|
"allowPrivateNetwork": false,
|
||||||
"args": {}
|
"args": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import ava, { TestFn } from 'ava';
|
|||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import { AppModule } from '../../app.module';
|
import { AppModule } from '../../app.module';
|
||||||
import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base';
|
import {
|
||||||
|
ConfigFactory,
|
||||||
|
InvalidOauthResponse,
|
||||||
|
type SafeFetchOptions,
|
||||||
|
URLHelper,
|
||||||
|
} from '../../base';
|
||||||
import { SessionCache } from '../../base/cache';
|
import { SessionCache } from '../../base/cache';
|
||||||
import { ConfigModule } from '../../base/config';
|
import { ConfigModule } from '../../base/config';
|
||||||
import { CurrentUser } from '../../core/auth';
|
import { CurrentUser } from '../../core/auth';
|
||||||
@@ -531,6 +536,7 @@ function createOidcRegistrationHarness(config?: {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
|
allowPrivateNetwork?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const server = {
|
const server = {
|
||||||
enableFeature: Sinon.spy(),
|
enableFeature: Sinon.spy(),
|
||||||
@@ -551,6 +557,7 @@ function createOidcRegistrationHarness(config?: {
|
|||||||
clientId: config?.clientId ?? 'oidc-client-id',
|
clientId: config?.clientId ?? 'oidc-client-id',
|
||||||
clientSecret: config?.clientSecret ?? 'oidc-client-secret',
|
clientSecret: config?.clientSecret ?? 'oidc-client-secret',
|
||||||
issuer: config?.issuer ?? 'https://issuer.affine.dev',
|
issuer: config?.issuer ?? 'https://issuer.affine.dev',
|
||||||
|
allowPrivateNetwork: config?.allowPrivateNetwork ?? false,
|
||||||
args: {},
|
args: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -565,6 +572,12 @@ function createOidcRegistrationHarness(config?: {
|
|||||||
provider,
|
provider,
|
||||||
factory,
|
factory,
|
||||||
server,
|
server,
|
||||||
|
fetchOptions: (url: string) =>
|
||||||
|
(
|
||||||
|
provider as unknown as {
|
||||||
|
fetchOptions(url: string): SafeFetchOptions;
|
||||||
|
}
|
||||||
|
).fetchOptions(url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,7 +953,16 @@ test('oidc should not fall back to default email claim when custom claim is conf
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('oidc discovery should remove oauth feature on failure and restore it after backoff retry succeeds', async t => {
|
test('oidc discovery should remove oauth feature on failure and restore it after backoff retry succeeds', async t => {
|
||||||
const { provider, factory, server } = createOidcRegistrationHarness();
|
const issuer = 'https://auth.internal/application/o/affine/';
|
||||||
|
const { fetchOptions: defaultFetchOptions } = createOidcRegistrationHarness({
|
||||||
|
issuer,
|
||||||
|
});
|
||||||
|
t.falsy(defaultFetchOptions(issuer).allowPrivateTargetOrigin);
|
||||||
|
|
||||||
|
const { provider, factory, server } = createOidcRegistrationHarness({
|
||||||
|
issuer,
|
||||||
|
allowPrivateNetwork: true,
|
||||||
|
});
|
||||||
const fetchStub = Sinon.stub(provider as any, 'oidcFetch');
|
const fetchStub = Sinon.stub(provider as any, 'oidcFetch');
|
||||||
const scheduledRetries: Array<() => void> = [];
|
const scheduledRetries: Array<() => void> = [];
|
||||||
const retryDelays: number[] = [];
|
const retryDelays: number[] = [];
|
||||||
@@ -967,11 +989,11 @@ test('oidc discovery should remove oauth feature on failure and restore it after
|
|||||||
.resolves(
|
.resolves(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
authorization_endpoint: 'https://issuer.affine.dev/auth',
|
authorization_endpoint: `${issuer}authorize`,
|
||||||
token_endpoint: 'https://issuer.affine.dev/token',
|
token_endpoint: `${issuer}token`,
|
||||||
userinfo_endpoint: 'https://issuer.affine.dev/userinfo',
|
userinfo_endpoint: `${issuer}userinfo`,
|
||||||
issuer: 'https://issuer.affine.dev',
|
issuer,
|
||||||
jwks_uri: 'https://issuer.affine.dev/jwks',
|
jwks_uri: `${issuer}jwks`,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -986,6 +1008,11 @@ test('oidc discovery should remove oauth feature on failure and restore it after
|
|||||||
t.deepEqual(factory.providers, []);
|
t.deepEqual(factory.providers, []);
|
||||||
t.true(server.disableFeature.calledWith(ServerFeature.OAuth));
|
t.true(server.disableFeature.calledWith(ServerFeature.OAuth));
|
||||||
t.is(fetchStub.callCount, 1);
|
t.is(fetchStub.callCount, 1);
|
||||||
|
t.is(
|
||||||
|
fetchStub.firstCall.args[0],
|
||||||
|
`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`
|
||||||
|
);
|
||||||
|
t.true(fetchStub.firstCall.args[2].allowPrivateTargetOrigin);
|
||||||
t.deepEqual(retryDelays, [1000]);
|
t.deepEqual(retryDelays, [1000]);
|
||||||
|
|
||||||
const firstRetry = scheduledRetries.shift();
|
const firstRetry = scheduledRetries.shift();
|
||||||
|
|||||||
@@ -280,12 +280,24 @@ export const USER_FRIENDLY_ERRORS = {
|
|||||||
args: { reason: 'string' },
|
args: { reason: 'string' },
|
||||||
message: ({ reason }) => {
|
message: ({ reason }) => {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
|
case 'invalid_url':
|
||||||
|
return 'Invalid URL';
|
||||||
|
case 'disallowed_protocol':
|
||||||
|
return 'URL protocol is not allowed';
|
||||||
|
case 'url_has_credentials':
|
||||||
|
return 'URL must not contain credentials';
|
||||||
|
case 'blocked_hostname':
|
||||||
|
return 'URL hostname is not allowed';
|
||||||
|
case 'host_not_allowed':
|
||||||
|
return 'URL hostname is outside the allowed hosts';
|
||||||
case 'unresolvable_hostname':
|
case 'unresolvable_hostname':
|
||||||
return 'Failed to resolve hostname';
|
return 'Failed to resolve hostname';
|
||||||
|
case 'blocked_ip':
|
||||||
|
return 'URL resolves to a private or reserved IP address';
|
||||||
case 'too_many_redirects':
|
case 'too_many_redirects':
|
||||||
return 'Too many redirects';
|
return 'Too many redirects';
|
||||||
default:
|
default:
|
||||||
return 'Invalid URL';
|
return `URL blocked by SSRF protection: ${reason}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type OIDCArgs = {
|
|||||||
|
|
||||||
export interface OAuthOIDCProviderConfig extends OAuthProviderConfig {
|
export interface OAuthOIDCProviderConfig extends OAuthProviderConfig {
|
||||||
issuer: string;
|
issuer: string;
|
||||||
|
allowPrivateNetwork?: boolean;
|
||||||
args?: OIDCArgs;
|
args?: OIDCArgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,22 @@ const schema: JSONSchema = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const oidcSchema: JSONSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
...schema.properties,
|
||||||
|
issuer: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'OIDC issuer URL',
|
||||||
|
},
|
||||||
|
allowPrivateNetwork: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Allow the OIDC issuer origin to resolve to private network addresses',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
defineModuleConfig('oauth', {
|
defineModuleConfig('oauth', {
|
||||||
'providers.google': {
|
'providers.google': {
|
||||||
desc: 'Google OAuth provider config',
|
desc: 'Google OAuth provider config',
|
||||||
@@ -69,14 +86,15 @@ defineModuleConfig('oauth', {
|
|||||||
link: 'https://docs.github.com/en/apps/oauth-apps',
|
link: 'https://docs.github.com/en/apps/oauth-apps',
|
||||||
},
|
},
|
||||||
'providers.oidc': {
|
'providers.oidc': {
|
||||||
desc: 'OIDC OAuth provider config',
|
desc: 'OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true',
|
||||||
default: {
|
default: {
|
||||||
clientId: '',
|
clientId: '',
|
||||||
clientSecret: '',
|
clientSecret: '',
|
||||||
issuer: '',
|
issuer: '',
|
||||||
|
allowPrivateNetwork: false,
|
||||||
args: {},
|
args: {},
|
||||||
},
|
},
|
||||||
schema,
|
schema: oidcSchema,
|
||||||
link: 'https://openid.net/specs/openid-connect-core-1_0.html',
|
link: 'https://openid.net/specs/openid-connect-core-1_0.html',
|
||||||
shape: z.object({
|
shape: z.object({
|
||||||
issuer: z
|
issuer: z
|
||||||
@@ -84,6 +102,7 @@ defineModuleConfig('oauth', {
|
|||||||
.url()
|
.url()
|
||||||
.regex(/^https?:\/\//, 'issuer must be a valid URL')
|
.regex(/^https?:\/\//, 'issuer must be a valid URL')
|
||||||
.or(z.string().length(0)),
|
.or(z.string().length(0)),
|
||||||
|
allowPrivateNetwork: z.boolean().optional(),
|
||||||
args: z.object({
|
args: z.object({
|
||||||
scope: z.string().optional(),
|
scope: z.string().optional(),
|
||||||
claim_id: z.string().optional(),
|
claim_id: z.string().optional(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
InvalidOauthResponse,
|
InvalidOauthResponse,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
safeFetch,
|
safeFetch,
|
||||||
|
type SafeFetchOptions,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import { OAuthProviderName } from '../config';
|
import { OAuthProviderName } from '../config';
|
||||||
import { OAuthProviderFactory } from '../factory';
|
import { OAuthProviderFactory } from '../factory';
|
||||||
@@ -79,6 +80,15 @@ export abstract class OAuthProvider {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected fetchOptions(_url: string | URL): SafeFetchOptions {
|
||||||
|
return {
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
maxRedirects: 3,
|
||||||
|
maxBytes: 1024 * 1024,
|
||||||
|
allowedHeaders: ['authorization', 'content-type', 'accept'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected async fetchJson<T>(
|
protected async fetchJson<T>(
|
||||||
url: string,
|
url: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
@@ -87,12 +97,7 @@ export abstract class OAuthProvider {
|
|||||||
const response = await safeFetch(
|
const response = await safeFetch(
|
||||||
url,
|
url,
|
||||||
{ ...init, headers: { ...init?.headers, Accept: 'application/json' } },
|
{ ...init, headers: { ...init?.headers, Accept: 'application/json' } },
|
||||||
{
|
this.fetchOptions(url)
|
||||||
timeoutMs: 10_000,
|
|
||||||
maxRedirects: 3,
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
allowedHeaders: ['authorization', 'content-type', 'accept'],
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
InvalidAuthState,
|
InvalidAuthState,
|
||||||
InvalidOauthResponse,
|
InvalidOauthResponse,
|
||||||
safeFetch,
|
safeFetch,
|
||||||
|
type SafeFetchOptions,
|
||||||
|
SsrfBlockedError,
|
||||||
URLHelper,
|
URLHelper,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config';
|
import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config';
|
||||||
@@ -65,13 +67,6 @@ type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
|
|||||||
|
|
||||||
const OIDC_DISCOVERY_INITIAL_RETRY_DELAY = 1000;
|
const OIDC_DISCOVERY_INITIAL_RETRY_DELAY = 1000;
|
||||||
const OIDC_DISCOVERY_MAX_RETRY_DELAY = 60_000;
|
const OIDC_DISCOVERY_MAX_RETRY_DELAY = 60_000;
|
||||||
const OIDC_FETCH_OPTIONS = {
|
|
||||||
timeoutMs: 10_000,
|
|
||||||
maxRedirects: 3,
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
allowedHeaders: ['accept'],
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
|
export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
|
||||||
override provider = OAuthProviderName.OIDC;
|
override provider = OAuthProviderName.OIDC;
|
||||||
@@ -96,6 +91,24 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override fetchOptions(rawUrl: string | URL): SafeFetchOptions {
|
||||||
|
const options = super.fetchOptions(rawUrl);
|
||||||
|
const config = this.config as OAuthOIDCProviderConfig;
|
||||||
|
if (!config.allowPrivateNetwork) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const issuer = new URL(config.issuer);
|
||||||
|
const target = new URL(rawUrl);
|
||||||
|
if (target.origin === issuer.origin) {
|
||||||
|
return { ...options, allowPrivateTargetOrigin: true };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
private get endpoints() {
|
private get endpoints() {
|
||||||
if (!this.#endpoints) {
|
if (!this.#endpoints) {
|
||||||
throw new Error('OIDC provider is not configured');
|
throw new Error('OIDC provider is not configured');
|
||||||
@@ -145,10 +158,11 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const discoveryUrl = `${this.normalizeIssuer(config.issuer)}/.well-known/openid-configuration`;
|
||||||
const res = await this.oidcFetch(
|
const res = await this.oidcFetch(
|
||||||
`${config.issuer}/.well-known/openid-configuration`,
|
discoveryUrl,
|
||||||
{ method: 'GET', headers: { Accept: 'application/json' } },
|
{ method: 'GET', headers: { Accept: 'application/json' } },
|
||||||
OIDC_FETCH_OPTIONS
|
this.fetchOptions(discoveryUrl)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (generation !== this.#validationGeneration) {
|
if (generation !== this.#validationGeneration) {
|
||||||
@@ -176,7 +190,7 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
|
|||||||
this.#endpoints = configuration;
|
this.#endpoints = configuration;
|
||||||
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri), {
|
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri), {
|
||||||
[customFetch]: (url, init) =>
|
[customFetch]: (url, init) =>
|
||||||
this.oidcFetch(url, init, OIDC_FETCH_OPTIONS),
|
this.oidcFetch(url, init, this.fetchOptions(url)),
|
||||||
});
|
});
|
||||||
this.#retryScheduler.reset();
|
this.#retryScheduler.reset();
|
||||||
super.setup();
|
super.setup();
|
||||||
@@ -184,7 +198,14 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
|
|||||||
if (generation !== this.#validationGeneration) {
|
if (generation !== this.#validationGeneration) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.logger.error('Failed to validate OIDC configuration', e);
|
const reason = e instanceof SsrfBlockedError ? e.data?.reason : undefined;
|
||||||
|
const message =
|
||||||
|
reason === 'blocked_ip' && !config.allowPrivateNetwork
|
||||||
|
? 'Failed to validate OIDC configuration: issuer resolves to a private network address; set oauth.providers.oidc.allowPrivateNetwork to true to trust this origin'
|
||||||
|
: reason
|
||||||
|
? `Failed to validate OIDC configuration: ${reason}`
|
||||||
|
: 'Failed to validate OIDC configuration';
|
||||||
|
this.logger.error(message, e);
|
||||||
this.onValidationFailure(generation);
|
this.onValidationFailure(generation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -482,7 +482,7 @@
|
|||||||
},
|
},
|
||||||
"providers.oidc": {
|
"providers.oidc": {
|
||||||
"type": "Object",
|
"type": "Object",
|
||||||
"desc": "OIDC OAuth provider config",
|
"desc": "OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true",
|
||||||
"link": "https://openid.net/specs/openid-connect-core-1_0.html"
|
"link": "https://openid.net/specs/openid-connect-core-1_0.html"
|
||||||
},
|
},
|
||||||
"providers.apple": {
|
"providers.apple": {
|
||||||
|
|||||||
@@ -56,7 +56,20 @@ private data class TokenPair(
|
|||||||
val json: String,
|
val json: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private class AuthServerException(val code: String?, val status: Int) : Exception(code)
|
private class AuthServerException(
|
||||||
|
val code: String?,
|
||||||
|
val status: Int,
|
||||||
|
override val message: String,
|
||||||
|
) : Exception(message)
|
||||||
|
|
||||||
|
private fun authServerException(status: Int, text: String): AuthServerException {
|
||||||
|
val body = runCatching { JSONObject(text) }.getOrNull()
|
||||||
|
val code = body?.optString("code")?.takeIf { it.isNotEmpty() }
|
||||||
|
?: body?.optString("name")?.takeIf { it.isNotEmpty() }
|
||||||
|
val message = body?.optString("message")?.takeIf { it.isNotEmpty() }
|
||||||
|
?: "Authentication request failed with status $status"
|
||||||
|
return AuthServerException(code, status, message)
|
||||||
|
}
|
||||||
|
|
||||||
private val permanentAuthErrors = setOf(
|
private val permanentAuthErrors = setOf(
|
||||||
"ACCESS_TOKEN_INVALID", "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED",
|
"ACCESS_TOKEN_INVALID", "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED",
|
||||||
@@ -183,8 +196,7 @@ internal class AuthSessionBroker(
|
|||||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||||
val text = response.body.string()
|
val text = response.body.string()
|
||||||
if (response.code < 400) return text
|
if (response.code < 400) return text
|
||||||
val code = runCatching { JSONObject(text).optString("code").ifEmpty { null } }.getOrNull()
|
val error = authServerException(response.code, text)
|
||||||
val error = AuthServerException(code, response.code)
|
|
||||||
if (response.code < 500 || attempt == 2) throw error
|
if (response.code < 500 || attempt == 2) throw error
|
||||||
last = error
|
last = error
|
||||||
}
|
}
|
||||||
@@ -338,7 +350,7 @@ class AuthPlugin : Plugin() {
|
|||||||
.build()
|
.build()
|
||||||
val exchangeCode = AuthHttp.client.newCall(request).executeAsync().use { response ->
|
val exchangeCode = AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||||
val text = response.body.string()
|
val text = response.body.string()
|
||||||
if (response.code >= 400) throw IllegalStateException(text)
|
if (response.code >= 400) throw authServerException(response.code, text)
|
||||||
JSONObject(text).getString("exchangeCode")
|
JSONObject(text).getString("exchangeCode")
|
||||||
}
|
}
|
||||||
val exchangeBody = JSONObject()
|
val exchangeBody = JSONObject()
|
||||||
@@ -353,7 +365,7 @@ class AuthPlugin : Plugin() {
|
|||||||
.build()
|
.build()
|
||||||
val tokenResponse = AuthHttp.client.newCall(exchangeRequest).executeAsync().use { response ->
|
val tokenResponse = AuthHttp.client.newCall(exchangeRequest).executeAsync().use { response ->
|
||||||
val text = response.body.string()
|
val text = response.body.string()
|
||||||
if (response.code >= 400) throw IllegalStateException(text)
|
if (response.code >= 400) throw authServerException(response.code, text)
|
||||||
text
|
text
|
||||||
}
|
}
|
||||||
broker.store(endpoint, tokenResponse)
|
broker.store(endpoint, tokenResponse)
|
||||||
@@ -385,7 +397,8 @@ class AuthPlugin : Plugin() {
|
|||||||
?: "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
|
?: "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
|
||||||
else -> "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
|
else -> "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
|
||||||
}
|
}
|
||||||
call.reject("Auth operation failed", code, error)
|
val message = if (error is AuthServerException) error.message else "Auth operation failed"
|
||||||
|
call.reject(message, code, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
type AuthTokenResponse,
|
type AuthTokenResponse,
|
||||||
classifyAuthError,
|
classifyAuthError,
|
||||||
} from '@affine/auth';
|
} from '@affine/auth';
|
||||||
import { app, net, safeStorage } from 'electron';
|
import { app, safeStorage } from 'electron';
|
||||||
|
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
|
import { authFetch } from './transport';
|
||||||
|
|
||||||
const FILEPATH = path.join(app.getPath('userData'), 'auth-sessions.json');
|
const FILEPATH = path.join(app.getPath('userData'), 'auth-sessions.json');
|
||||||
const TEMP_FILEPATH = `${FILEPATH}.tmp`;
|
const TEMP_FILEPATH = `${FILEPATH}.tmp`;
|
||||||
@@ -105,7 +106,7 @@ function storage(endpoint: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(endpoint: string, refreshToken: string) {
|
async function refresh(endpoint: string, refreshToken: string) {
|
||||||
const response = await net.fetch(
|
const response = await authFetch(
|
||||||
new URL('/api/auth/session/refresh', endpoint).toString(),
|
new URL('/api/auth/session/refresh', endpoint).toString(),
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -192,7 +193,7 @@ export async function revokeAuthSession(endpoint: string) {
|
|||||||
await getAuthSessionBroker(normalized).revoke(
|
await getAuthSessionBroker(normalized).revoke(
|
||||||
'sign-out',
|
'sign-out',
|
||||||
async (refreshToken: string) => {
|
async (refreshToken: string) => {
|
||||||
const response = await net.fetch(
|
const response = await authFetch(
|
||||||
new URL('/api/auth/session/revoke', normalized).toString(),
|
new URL('/api/auth/session/revoke', normalized).toString(),
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
|
||||||
import type { AuthTokenResponse } from '@affine/auth';
|
import type { AuthTokenResponse } from '@affine/auth';
|
||||||
import { net, session } from 'electron';
|
import { session } from 'electron';
|
||||||
|
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
import type { NamespaceHandlers } from '../type';
|
import type { NamespaceHandlers } from '../type';
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
revokeAuthSession,
|
revokeAuthSession,
|
||||||
setAuthSession,
|
setAuthSession,
|
||||||
} from './auth-session';
|
} from './auth-session';
|
||||||
|
import { authFetch, getAuthTransportSession } from './transport';
|
||||||
|
|
||||||
export interface SignInResponse {
|
export interface SignInResponse {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -47,14 +48,21 @@ function authUrl(endpoint: string, path: string) {
|
|||||||
async function readJson<T>(response: Response): Promise<T> {
|
async function readJson<T>(response: Response): Promise<T> {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(text || response.statusText);
|
let message = text || response.statusText;
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(text);
|
||||||
|
if (typeof error.message === 'string') {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return text ? JSON.parse(text) : ({} as T);
|
return text ? JSON.parse(text) : ({} as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAuth(endpoint: string, path: string, body?: unknown) {
|
async function fetchAuth(endpoint: string, path: string, body?: unknown) {
|
||||||
return await net.fetch(authUrl(endpoint, path), {
|
return await authFetch(authUrl(endpoint, path), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
@@ -66,9 +74,11 @@ async function fetchAuth(endpoint: string, path: string, body?: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearAuthCookies(endpoint: string) {
|
async function clearAuthCookies(endpoint: string) {
|
||||||
|
const sessions = [session.defaultSession, getAuthTransportSession()];
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
sessions.flatMap(authSession =>
|
||||||
authCookieNames.map(name =>
|
authCookieNames.map(name =>
|
||||||
session.defaultSession.cookies
|
authSession.cookies
|
||||||
.remove(endpoint, name)
|
.remove(endpoint, name)
|
||||||
.catch(error =>
|
.catch(error =>
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -79,6 +89,7 @@ async function clearAuthCookies(endpoint: string) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +159,7 @@ export const authHandlers = {
|
|||||||
challenge?: string;
|
challenge?: string;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const response = await net.fetch(authUrl(endpoint, '/api/auth/sign-in'), {
|
const response = await authFetch(authUrl(endpoint, '/api/auth/sign-in'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
|
|||||||
11
packages/frontend/apps/electron/src/main/auth/transport.ts
Normal file
11
packages/frontend/apps/electron/src/main/auth/transport.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { session } from 'electron';
|
||||||
|
|
||||||
|
const AUTH_TRANSPORT_PARTITION = 'affine-auth-transport';
|
||||||
|
|
||||||
|
export function getAuthTransportSession() {
|
||||||
|
return session.fromPartition(AUTH_TRANSPORT_PARTITION, { cache: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authFetch(input: string | Request, init?: RequestInit) {
|
||||||
|
return getAuthTransportSession().fetch(input, init);
|
||||||
|
}
|
||||||
@@ -18,6 +18,36 @@ if (isDev) {
|
|||||||
protocol = 'affine-dev';
|
protocol = 'affine-dev';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authMethods = new Set(['magic-link', 'oauth', 'open-app-signin']);
|
||||||
|
|
||||||
|
function summarizeDeepLink(rawUrl: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
const method = url.searchParams.get('method');
|
||||||
|
const server = url.searchParams.get('server');
|
||||||
|
let serverOrigin: string | undefined;
|
||||||
|
try {
|
||||||
|
serverOrigin = server ? new URL(server).origin : undefined;
|
||||||
|
} catch {
|
||||||
|
serverOrigin = undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
protocol: url.protocol,
|
||||||
|
action: url.hostname,
|
||||||
|
method: method && authMethods.has(method) ? method : undefined,
|
||||||
|
serverOrigin,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logDeepLinkFailure(rawUrl: string, error: unknown) {
|
||||||
|
logger.error('failed to handle affine url', summarizeDeepLink(rawUrl), {
|
||||||
|
error: error instanceof Error ? error.name : typeof error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function setupDeepLink(app: App) {
|
export function setupDeepLink(app: App) {
|
||||||
if (process.defaultApp) {
|
if (process.defaultApp) {
|
||||||
if (process.argv.length >= 2) {
|
if (process.argv.length >= 2) {
|
||||||
@@ -30,14 +60,14 @@ export function setupDeepLink(app: App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.on('open-url', (event, url) => {
|
app.on('open-url', (event, url) => {
|
||||||
logger.log('open-url', url);
|
logger.log('open-url', summarizeDeepLink(url));
|
||||||
if (url.startsWith(`${protocol}://`)) {
|
if (url.startsWith(`${protocol}://`)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(() => handleAffineUrl(url))
|
.then(() => handleAffineUrl(url))
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
logger.error('failed to handle affine url', e);
|
logDeepLinkFailure(url, e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -55,7 +85,7 @@ export function setupDeepLink(app: App) {
|
|||||||
if (url?.startsWith(`${protocol}://`)) {
|
if (url?.startsWith(`${protocol}://`)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleAffineUrl(url).catch(e => {
|
handleAffineUrl(url).catch(e => {
|
||||||
logger.error('failed to handle affine url', e);
|
logDeepLinkFailure(url, e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -66,10 +96,15 @@ export function setupDeepLink(app: App) {
|
|||||||
// app may be brought up without having a running instance
|
// app may be brought up without having a running instance
|
||||||
// need to read the url from the command line
|
// need to read the url from the command line
|
||||||
const url = process.argv.at(-1);
|
const url = process.argv.at(-1);
|
||||||
logger.log('url from argv', process.argv, url);
|
logger.log(
|
||||||
|
'url from argv',
|
||||||
|
url?.startsWith(`${protocol}://`)
|
||||||
|
? summarizeDeepLink(url)
|
||||||
|
: { deepLink: false, argumentCount: process.argv.length }
|
||||||
|
);
|
||||||
if (url?.startsWith(`${protocol}://`)) {
|
if (url?.startsWith(`${protocol}://`)) {
|
||||||
handleAffineUrl(url).catch(e => {
|
handleAffineUrl(url).catch(e => {
|
||||||
logger.error('failed to handle affine url', e);
|
logDeepLinkFailure(url, e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -78,7 +113,7 @@ export function setupDeepLink(app: App) {
|
|||||||
async function handleAffineUrl(url: string) {
|
async function handleAffineUrl(url: string) {
|
||||||
await showMainWindow();
|
await showMainWindow();
|
||||||
|
|
||||||
logger.info('open affine url', url);
|
logger.info('open affine url', summarizeDeepLink(url));
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
|
|
||||||
if (urlObj.hostname === 'authentication') {
|
if (urlObj.hostname === 'authentication') {
|
||||||
@@ -93,7 +128,7 @@ async function handleAffineUrl(url: string) {
|
|||||||
method !== 'open-app-signin') ||
|
method !== 'open-app-signin') ||
|
||||||
!payload
|
!payload
|
||||||
) {
|
) {
|
||||||
logger.error('Invalid authentication url', url);
|
logger.error('Invalid authentication url', summarizeDeepLink(url));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const registerHandlers = () => {
|
|||||||
return await handleIpcMessage(e, ...args);
|
return await handleIpcMessage(e, ...args);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`error in ipc handler when calling ${args[0]}`, error);
|
logger.error(`error in ipc handler when calling ${args[0]}`, error);
|
||||||
return null;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const runtime = vi.hoisted(() => ({
|
|||||||
failWrite: false,
|
failWrite: false,
|
||||||
files: new Map<string, string>(),
|
files: new Map<string, string>(),
|
||||||
fetch: vi.fn(),
|
fetch: vi.fn(),
|
||||||
|
fromPartition: vi.fn(),
|
||||||
rename: vi.fn(),
|
rename: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -41,7 +42,12 @@ vi.mock('node:fs/promises', () => ({
|
|||||||
|
|
||||||
vi.mock('electron', () => ({
|
vi.mock('electron', () => ({
|
||||||
app: { getPath: () => '/test-user-data' },
|
app: { getPath: () => '/test-user-data' },
|
||||||
net: { fetch: runtime.fetch },
|
session: {
|
||||||
|
fromPartition: (...args: unknown[]) => {
|
||||||
|
runtime.fromPartition(...args);
|
||||||
|
return { fetch: runtime.fetch };
|
||||||
|
},
|
||||||
|
},
|
||||||
safeStorage: {
|
safeStorage: {
|
||||||
isEncryptionAvailable: () => runtime.encryptionAvailable,
|
isEncryptionAvailable: () => runtime.encryptionAvailable,
|
||||||
getSelectedStorageBackend: () => runtime.backend,
|
getSelectedStorageBackend: () => runtime.backend,
|
||||||
@@ -68,6 +74,7 @@ beforeEach(() => {
|
|||||||
runtime.backend = 'unknown';
|
runtime.backend = 'unknown';
|
||||||
runtime.failWrite = false;
|
runtime.failWrite = false;
|
||||||
runtime.fetch.mockReset();
|
runtime.fetch.mockReset();
|
||||||
|
runtime.fromPartition.mockClear();
|
||||||
runtime.rename.mockClear();
|
runtime.rename.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,6 +145,9 @@ test('shares one refresh across concurrent main-process callers', async () => {
|
|||||||
|
|
||||||
expect(tokens.every(token => token === 'fresh')).toBe(true);
|
expect(tokens.every(token => token === 'fresh')).toBe(true);
|
||||||
expect(runtime.fetch).toHaveBeenCalledTimes(1);
|
expect(runtime.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runtime.fromPartition).toHaveBeenCalledWith('affine-auth-transport', {
|
||||||
|
cache: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves credentials for unknown refresh errors', async () => {
|
test('preserves credentials for unknown refresh errors', async () => {
|
||||||
|
|||||||
@@ -29,11 +29,16 @@ private struct StoredAuthTokenPair: Codable {
|
|||||||
|
|
||||||
private struct AuthErrorResponse: Decodable {
|
private struct AuthErrorResponse: Decodable {
|
||||||
let code: String?
|
let code: String?
|
||||||
|
let name: String?
|
||||||
|
let message: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct AuthServerError: Error {
|
private struct AuthServerError: Error, CustomStringConvertible {
|
||||||
let code: String?
|
let code: String?
|
||||||
let statusCode: Int
|
let statusCode: Int
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var description: String { message }
|
||||||
|
|
||||||
var permanentlyInvalidatesSession: Bool {
|
var permanentlyInvalidatesSession: Bool {
|
||||||
switch code {
|
switch code {
|
||||||
@@ -46,6 +51,14 @@ private struct AuthServerError: Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func authServerError(_ data: Data, statusCode: Int) -> AuthServerError {
|
||||||
|
let response = try? JSONDecoder().decode(AuthErrorResponse.self, from: data)
|
||||||
|
return AuthServerError(
|
||||||
|
code: response?.code ?? response?.name,
|
||||||
|
statusCode: statusCode,
|
||||||
|
message: response?.message ?? "Authentication request failed with status \(statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
private struct AuthOperationCancelled: Error {}
|
private struct AuthOperationCancelled: Error {}
|
||||||
|
|
||||||
private struct AuthRefreshOperation {
|
private struct AuthRefreshOperation {
|
||||||
@@ -63,7 +76,8 @@ private actor AuthSessionBroker {
|
|||||||
try write(endpoint, tokenPair(response))
|
try write(endpoint, tokenPair(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
func validAccessToken(_ endpoint: String, minValidity: TimeInterval = 120) async throws -> String? {
|
func validAccessToken(_ endpoint: String, minValidity: TimeInterval = 120) async throws -> String?
|
||||||
|
{
|
||||||
guard let pair = try read(endpoint) else { return nil }
|
guard let pair = try read(endpoint) else { return nil }
|
||||||
if pair.accessExpiresAt.timeIntervalSinceNow > minValidity {
|
if pair.accessExpiresAt.timeIntervalSinceNow > minValidity {
|
||||||
return pair.accessToken
|
return pair.accessToken
|
||||||
@@ -149,7 +163,9 @@ private actor AuthSessionBroker {
|
|||||||
session: response.session)
|
session: response.session)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func request(_ endpoint: String, action: String, body: [String: String]) async throws -> Data {
|
private func request(_ endpoint: String, action: String, body: [String: String]) async throws
|
||||||
|
-> Data
|
||||||
|
{
|
||||||
guard let url = URL(string: "\(canonicalEndpoint(endpoint))\(action)") else {
|
guard let url = URL(string: "\(canonicalEndpoint(endpoint))\(action)") else {
|
||||||
throw AuthError.invalidEndpoint
|
throw AuthError.invalidEndpoint
|
||||||
}
|
}
|
||||||
@@ -168,9 +184,7 @@ private actor AuthSessionBroker {
|
|||||||
throw AuthError.internalError
|
throw AuthError.internalError
|
||||||
}
|
}
|
||||||
if response.statusCode < 400 { return data }
|
if response.statusCode < 400 { return data }
|
||||||
let error = AuthServerError(
|
let error = authServerError(data, statusCode: response.statusCode)
|
||||||
code: try? JSONDecoder().decode(AuthErrorResponse.self, from: data).code,
|
|
||||||
statusCode: response.statusCode)
|
|
||||||
guard response.statusCode >= 500, attempt < 2 else { throw error }
|
guard response.statusCode >= 500, attempt < 2 else { throw error }
|
||||||
} catch let error as AuthServerError {
|
} catch let error as AuthServerError {
|
||||||
if error.statusCode < 500 || attempt == 2 { throw error }
|
if error.statusCode < 500 || attempt == 2 { throw error }
|
||||||
@@ -336,12 +350,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
], body: ["email": email, "token": token, "client_nonce": clientNonce])
|
], body: ["email": email, "token": token, "client_nonce": clientNonce])
|
||||||
|
|
||||||
if response.statusCode >= 400 {
|
if response.statusCode >= 400 {
|
||||||
if let textBody = String(data: data, encoding: .utf8) {
|
throw authServerError(data, statusCode: response.statusCode)
|
||||||
call.reject(textBody)
|
|
||||||
} else {
|
|
||||||
call.reject("Failed to sign in")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await self.exchangeSession(endpoint, data)
|
try await self.exchangeSession(endpoint, data)
|
||||||
@@ -367,12 +376,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
], body: ["code": code, "state": state, "client_nonce": clientNonce])
|
], body: ["code": code, "state": state, "client_nonce": clientNonce])
|
||||||
|
|
||||||
if response.statusCode >= 400 {
|
if response.statusCode >= 400 {
|
||||||
if let textBody = String(data: data, encoding: .utf8) {
|
throw authServerError(data, statusCode: response.statusCode)
|
||||||
call.reject(textBody)
|
|
||||||
} else {
|
|
||||||
call.reject("Failed to sign in")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await self.exchangeSession(endpoint, data)
|
try await self.exchangeSession(endpoint, data)
|
||||||
@@ -398,16 +402,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
"x-affine-client-kind": "native",
|
"x-affine-client-kind": "native",
|
||||||
"x-captcha-token": verifyToken,
|
"x-captcha-token": verifyToken,
|
||||||
"x-captcha-challenge": challenge,
|
"x-captcha-challenge": challenge,
|
||||||
"x-captcha-provider": verifyToken == nil ? nil : (challenge == nil ? "turnstile" : "hashcash"),
|
"x-captcha-provider": verifyToken == nil
|
||||||
|
? nil : (challenge == nil ? "turnstile" : "hashcash"),
|
||||||
], body: ["email": email, "password": password])
|
], body: ["email": email, "password": password])
|
||||||
|
|
||||||
if response.statusCode >= 400 {
|
if response.statusCode >= 400 {
|
||||||
if let textBody = String(data: data, encoding: .utf8) {
|
throw authServerError(data, statusCode: response.statusCode)
|
||||||
call.reject(textBody)
|
|
||||||
} else {
|
|
||||||
call.reject("Failed to sign in")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await self.exchangeSession(endpoint, data)
|
try await self.exchangeSession(endpoint, data)
|
||||||
@@ -431,12 +431,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
], body: ["code": code])
|
], body: ["code": code])
|
||||||
|
|
||||||
if response.statusCode >= 400 {
|
if response.statusCode >= 400 {
|
||||||
if let textBody = String(data: data, encoding: .utf8) {
|
throw authServerError(data, statusCode: response.statusCode)
|
||||||
call.reject(textBody)
|
|
||||||
} else {
|
|
||||||
call.reject("Failed to sign in")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await self.exchangeSession(endpoint, data)
|
try await self.exchangeSession(endpoint, data)
|
||||||
@@ -476,7 +471,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
endpoint, method: "POST", action: "/api/auth/session/exchange",
|
endpoint, method: "POST", action: "/api/auth/session/exchange",
|
||||||
headers: [
|
headers: [
|
||||||
"x-affine-client-kind": "native"
|
"x-affine-client-kind": "native"
|
||||||
], body: [
|
],
|
||||||
|
body: [
|
||||||
"code": code,
|
"code": code,
|
||||||
"installationId": self.installationId(),
|
"installationId": self.installationId(),
|
||||||
"platform": "ios",
|
"platform": "ios",
|
||||||
@@ -484,10 +480,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
])
|
])
|
||||||
|
|
||||||
if response.statusCode >= 400 {
|
if response.statusCode >= 400 {
|
||||||
throw AuthError.exchangeFailed
|
throw authServerError(data, statusCode: response.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
try await broker.store(endpoint, response: JSONDecoder().decode(AuthTokenResponse.self, from: data))
|
try await broker.store(
|
||||||
|
endpoint, response: JSONDecoder().decode(AuthTokenResponse.self, from: data))
|
||||||
self.clearAuthCookies(endpoint)
|
self.clearAuthCookies(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +503,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let normalizedHost = host.lowercased()
|
let normalizedHost = host.lowercased()
|
||||||
|
|
||||||
HTTPCookieStorage.shared.cookies?.forEach { cookie in
|
HTTPCookieStorage.shared.cookies?.forEach { cookie in
|
||||||
let domain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
let domain = cookie.domain.lowercased().trimmingCharacters(
|
||||||
|
in: CharacterSet(charactersIn: "."))
|
||||||
let domainMatches = normalizedHost == domain || normalizedHost.hasSuffix(".\(domain)")
|
let domainMatches = normalizedHost == domain || normalizedHost.hasSuffix(".\(domain)")
|
||||||
if domainMatches && authCookieNames.contains(cookie.name) {
|
if domainMatches && authCookieNames.contains(cookie.name) {
|
||||||
HTTPCookieStorage.shared.deleteCookie(cookie)
|
HTTPCookieStorage.shared.deleteCookie(cookie)
|
||||||
|
|||||||
@@ -9312,9 +9312,11 @@ export function useAFFiNEI18N(): {
|
|||||||
readonly message: string;
|
readonly message: string;
|
||||||
}): string;
|
}): string;
|
||||||
/**
|
/**
|
||||||
* `Invalid URL`
|
* `URL blocked by SSRF protection: {{reason}}`
|
||||||
*/
|
*/
|
||||||
["error.SSRF_BLOCKED_ERROR"](): string;
|
["error.SSRF_BLOCKED_ERROR"](options: {
|
||||||
|
readonly reason: string;
|
||||||
|
}): string;
|
||||||
/**
|
/**
|
||||||
* `Response too large ({{receivedBytes}} bytes), limit is {{limitBytes}} bytes`
|
* `Response too large ({{receivedBytes}} bytes), limit is {{limitBytes}} bytes`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2325,7 +2325,7 @@
|
|||||||
"error.BAD_REQUEST": "Bad request.",
|
"error.BAD_REQUEST": "Bad request.",
|
||||||
"error.GRAPHQL_BAD_REQUEST": "GraphQL bad request, code: {{code}}, {{message}}",
|
"error.GRAPHQL_BAD_REQUEST": "GraphQL bad request, code: {{code}}, {{message}}",
|
||||||
"error.HTTP_REQUEST_ERROR": "HTTP request error, message: {{message}}",
|
"error.HTTP_REQUEST_ERROR": "HTTP request error, message: {{message}}",
|
||||||
"error.SSRF_BLOCKED_ERROR": "Invalid URL",
|
"error.SSRF_BLOCKED_ERROR": "URL blocked by SSRF protection: {{reason}}",
|
||||||
"error.RESPONSE_TOO_LARGE_ERROR": "Response too large ({{receivedBytes}} bytes), limit is {{limitBytes}} bytes",
|
"error.RESPONSE_TOO_LARGE_ERROR": "Response too large ({{receivedBytes}} bytes), limit is {{limitBytes}} bytes",
|
||||||
"error.EMAIL_SERVICE_NOT_CONFIGURED": "Email service is not configured.",
|
"error.EMAIL_SERVICE_NOT_CONFIGURED": "Email service is not configured.",
|
||||||
"error.IMAGE_FORMAT_NOT_SUPPORTED": "Image format not supported: {{format}}",
|
"error.IMAGE_FORMAT_NOT_SUPPORTED": "Image format not supported: {{format}}",
|
||||||
|
|||||||
Reference in New Issue
Block a user