Skip to content
Open
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
5 changes: 5 additions & 0 deletions app/api/auth/sso/complete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto';
import {
exchangeCodeForTokens,
fetchUserInfoAvatar,
getRequiredConfig,
getTokenEndpoint,
} from '@/lib/oauth/token-exchange';
Expand Down Expand Up @@ -73,6 +74,8 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);

const avatarUrl = await fetchUserInfoAvatar(tokens.access_token, pendingServerId).catch(() => undefined);

// For the mobile handoff flow the tokens are handed back to the app
// verbatim - we deliberately don't write any cookies on the webmail
// origin (the mobile browser tab disposes of the session after the
Expand Down Expand Up @@ -110,12 +113,14 @@ export async function POST(request: NextRequest) {
server_url: serverUrl,
mobile_redirect_uri: mobileRedirectUri,
mobile_state: mobileState,
...(avatarUrl ? { avatar_url: avatarUrl } : {}),
});
}

return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
...(avatarUrl ? { avatar_url: avatarUrl } : {}),
});
} catch (error) {
// Clean up pending cookie on any error
Expand Down
5 changes: 4 additions & 1 deletion app/api/auth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { exchangeCodeForTokens, buildOAuthParams, fetchUserInfoAvatar, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';

Expand All @@ -27,9 +27,12 @@ export async function POST(request: NextRequest) {

const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);

const avatarUrl = await fetchUserInfoAvatar(tokens.access_token, serverId).catch(() => undefined);

const response = NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
...(avatarUrl ? { avatar_url: avatarUrl } : {}),
});

const cookieStore = await cookies();
Expand Down
1 change: 1 addition & 0 deletions components/layout/account-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?:
size="sm"
className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
disableFavicon
contactPhotoUri={account.avatarUrl}
fallbackColor={account.avatarColor}
/>
);
Expand Down
1 change: 1 addition & 0 deletions components/layout/navigation-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@ export function NavigationRail({
email={account.email || account.username}
size="sm"
disableFavicon
contactPhotoUri={account.avatarUrl}
fallbackColor={account.avatarColor}
/>
{isActive && (
Expand Down
1 change: 1 addition & 0 deletions components/protocol/protocol-account-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function ProtocolAccountPicker({
size="md"
className="shrink-0"
disableFavicon
contactPhotoUri={account.avatarUrl}
fallbackColor={account.avatarColor}
/>

Expand Down
1 change: 1 addition & 0 deletions components/settings/account-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ function AccountRow({
size="sm"
className="w-9 h-9 text-sm"
disableFavicon
contactPhotoUri={account.avatarUrl}
fallbackColor={account.avatarColor}
/>
{isActive && (
Expand Down
3 changes: 3 additions & 0 deletions lib/oauth/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface OAuthMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
revocation_endpoint?: string;
end_session_endpoint?: string;
}
Expand Down Expand Up @@ -104,6 +105,7 @@ async function attemptDiscovery(
const allPublic = await endpointsArePublic([
data.authorization_endpoint,
data.token_endpoint,
data.userinfo_endpoint,
data.revocation_endpoint,
data.end_session_endpoint,
], validate);
Expand All @@ -115,6 +117,7 @@ async function attemptDiscovery(
issuer: data.issuer,
authorization_endpoint: data.authorization_endpoint,
token_endpoint: data.token_endpoint,
userinfo_endpoint: data.userinfo_endpoint,
revocation_endpoint: data.revocation_endpoint,
end_session_endpoint: data.end_session_endpoint,
};
Expand Down
39 changes: 39 additions & 0 deletions lib/oauth/token-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,45 @@ export interface TokenResult {
refresh_token?: string;
}

export interface UserInfoResult {
picture?: string;
avatar?: string;
avatar_url?: string;
}

function avatarFromUserInfo(data: unknown): string | undefined {
if (!data || typeof data !== 'object') return undefined;
const record = data as Record<string, unknown>;
const value = record.picture || record.avatar || record.avatar_url;
if (typeof value !== 'string') return undefined;
try {
const url = new URL(value);
if (url.protocol !== 'https:') return undefined;
return url.toString();
} catch {
return undefined;
}
}

export async function fetchUserInfoAvatar(
accessToken: string,
serverId?: string | null,
): Promise<string | undefined> {
const metadata = await getMetadata(serverId);
if (!metadata?.userinfo_endpoint) return undefined;

const response = await fetch(metadata.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!response.ok) {
logger.warn('Userinfo request failed', { status: response.status });
return undefined;
}

return avatarFromUserInfo(await response.json());
}

export async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
Expand Down
Binary file modified public/apple-touch-icon-120x120.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/apple-touch-icon-152x152.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/apple-touch-icon-167x167.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/apple-touch-icon-180x180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/branding/Bulwark_Favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 1 addition & 3 deletions public/branding/Bulwark_Favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 1 addition & 3 deletions public/branding/Bulwark_Icon_App.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/branding/Bulwark_Logo_Color.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 1 addition & 3 deletions public/branding/Bulwark_Logo_Color.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/branding/Bulwark_Logo_Dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading