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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r

# Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API
# hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk
# returns a different RP ID or when multiple domains must be entitled.
# returns a different RP ID or when multiple domains must be entitled. Neither is needed
# when the provisioning profile does not grant Associated Domains.
# T3CODE_APPLE_TEAM_ID=ABC1234567
# T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile
# T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com
Expand Down
8 changes: 5 additions & 3 deletions docs/operations/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,11 @@ Notes:
- The workflow writes it to a temporary `AuthKey_<id>.p8` file at runtime.
- The workflow decodes `MACOS_PROVISIONING_PROFILE`, validates it with `security cms`, and passes it
to the desktop packager.
- In-app browser passkeys depend on the same profile. The packager adds each entitlement only when
the profile grants it, because macOS will not launch an app that claims more than its profile
allows. The build log reports which ones it enabled.
- The packager adds each passkey entitlement only when the profile grants it, because macOS will not
launch an app that claims more than its profile allows. The build log reports which ones it enabled.
- `com.apple.developer.associated-domains` (`webcredentials:` the Clerk RP domains) enables
T3 Connect passkey sign-in. A profile that grants it requires a Clerk domain; builds without
T3 Connect can use a profile without it.
- `keychain-access-groups` (`<TEAM_ID>.com.t3tools.t3code.webauthn`) enables Touch ID passkeys.
Profiles that grant the team's keychain groups (`<TEAM_ID>.*`) cover it.
- `com.apple.developer.web-browser.public-key-credential` lets the system passkey sheet (iCloud
Expand Down
54 changes: 48 additions & 6 deletions scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
InvalidMacPasskeyPublishableKeyError,
UnsupportedDesktopBuildArchitectureError,
MacPasskeySigningConfigurationResolutionError,
MissingMacPasskeyDomainConfigurationError,
MissingMacPasskeyProvisioningProfileError,
packWindowsServerAsar,
preflightWindowsDesktopBuild,
Expand Down Expand Up @@ -1423,6 +1424,13 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
rpDomains: ["example.clerk.accounts.dev"],
provisioningProfilePath: "/tmp/t3code.provisionprofile",
});
assert.deepStrictEqual(
resolveMacPasskeySigningConfiguration({
T3CODE_APPLE_TEAM_ID: "ABC1234567",
T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile",
}).rpDomains,
[],
);
});

it("normalizes explicit macOS passkey RP domains and renders required entitlements", () => {
Expand All @@ -1433,6 +1441,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
" Clerk.Example.com,example.clerk.accounts.dev,clerk.example.com ",
});
const entitlements = renderMacPasskeyEntitlements(configuration, {
associatedDomains: configuration.rpDomains,
touchIdKeychainAccessGroup: undefined,
browserPasskeys: false,
});
Expand All @@ -1452,7 +1461,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
});

it("grants in-app browser passkey entitlements only when the provisioning profile does", () => {
const configuration = { appId: "com.t3tools.t3code", teamId: "ABC1234567" };
const configuration = {
appId: "com.t3tools.t3code",
teamId: "ABC1234567",
rpDomains: ["clerk.example.com"],
};
const profile = (entitlements: string, outside = "") =>
`0\x82\x1f\x9a\x06\t*\x86H<?xml version="1.0"?><plist version="1.0"><dict>${outside}<key>Entitlements</key><dict>${entitlements}</dict></dict></plist>\x00\x01`;
const teamWildcardGroups = `<key>keychain-access-groups</key>
Expand All @@ -1462,6 +1475,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
</array>`;

assert.deepStrictEqual(resolveMacWebAuthnEntitlements(profile(""), configuration), {
associatedDomains: [],
touchIdKeychainAccessGroup: undefined,
browserPasskeys: false,
});
Expand All @@ -1470,7 +1484,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
profile("<key>keychain-access-groups</key><array><string>OTHERTEAM1.*</string></array>"),
configuration,
),
{ touchIdKeychainAccessGroup: undefined, browserPasskeys: false },
{ associatedDomains: [], touchIdKeychainAccessGroup: undefined, browserPasskeys: false },
);
assert.deepStrictEqual(
resolveMacWebAuthnEntitlements(
Expand All @@ -1482,7 +1496,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
),
configuration,
),
{ touchIdKeychainAccessGroup: undefined, browserPasskeys: false },
{ associatedDomains: [], touchIdKeychainAccessGroup: undefined, browserPasskeys: false },
);
assert.deepStrictEqual(
resolveMacWebAuthnEntitlements(
Expand All @@ -1494,20 +1508,48 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
configuration,
),
{
associatedDomains: [],
touchIdKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn",
browserPasskeys: true,
},
);
const associatedDomains = "<key>com.apple.developer.associated-domains</key><string>*</string>";
assert.deepStrictEqual(
resolveMacWebAuthnEntitlements(profile(associatedDomains), configuration).associatedDomains,
["clerk.example.com"],
);
assert.throws(
() =>
resolveMacWebAuthnEntitlements(profile(associatedDomains), {
...configuration,
rpDomains: [],
}),
MissingMacPasskeyDomainConfigurationError,
);

const browserOnly = renderMacPasskeyEntitlements(
configuration,
resolveMacWebAuthnEntitlements(
profile(`<key>com.apple.developer.web-browser.public-key-credential</key><true/>`),
{ ...configuration, rpDomains: [] },
),
);
assert.notInclude(browserOnly, "com.apple.developer.associated-domains");
assert.include(browserOnly, "com.apple.developer.web-browser.public-key-credential");

const entitlements = renderMacPasskeyEntitlements(
{ ...configuration, rpDomains: ["clerk.example.com"], provisioningProfilePath: "" },
configuration,
resolveMacWebAuthnEntitlements(
profile(
`${teamWildcardGroups}<key>com.apple.developer.web-browser.public-key-credential</key><true/>`,
`${associatedDomains}${teamWildcardGroups}<key>com.apple.developer.web-browser.public-key-credential</key><true/>`,
),
configuration,
),
);
assert.match(
entitlements,
/<key>com\.apple\.developer\.associated-domains<\/key>\s*<array>\s*<string>webcredentials:clerk\.example\.com<\/string>\s*<\/array>/u,
);
assert.match(
entitlements,
/<key>keychain-access-groups<\/key>\s*<array>\s*<string>ABC1234567\.com\.t3tools\.t3code\.webauthn<\/string>\s*<\/array>/u,
Expand Down Expand Up @@ -1535,7 +1577,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
assert.instanceOf(missingProfileError, MissingMacPasskeyProvisioningProfileError);
assert.equal(
missingProfileError.message,
"T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.",
"T3CODE_MACOS_PROVISIONING_PROFILE must point to a provisioning profile.",
);

const unsafeDomain =
Expand Down
69 changes: 32 additions & 37 deletions scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,7 +1168,7 @@ export class MissingMacPasskeyProvisioningProfileError extends Schema.TaggedErro
{},
) {
override get message(): string {
return "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.";
return "T3CODE_MACOS_PROVISIONING_PROFILE must point to a provisioning profile.";
}
}

Expand All @@ -1177,7 +1177,7 @@ export class MissingMacPasskeyDomainConfigurationError extends Schema.TaggedErro
{},
) {
override get message(): string {
return "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds.";
return "The provisioning profile grants Associated Domains, so T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required.";
}
}

Expand All @@ -1192,22 +1192,12 @@ export class InvalidMacPasskeyPublishableKeyError extends Schema.TaggedError<Inv
}
}

export class MissingMacPasskeyRpDomainError extends Schema.TaggedError<MissingMacPasskeyRpDomainError>()(
"MissingMacPasskeyRpDomainError",
{},
) {
override get message(): string {
return "At least one Clerk passkey RP domain is required.";
}
}

export const MacPasskeySigningConfigurationError = Schema.Union([
InvalidMacPasskeyRpDomainError,
InvalidAppleTeamIdError,
MissingMacPasskeyProvisioningProfileError,
MissingMacPasskeyDomainConfigurationError,
InvalidMacPasskeyPublishableKeyError,
MissingMacPasskeyRpDomainError,
]);
export type MacPasskeySigningConfigurationError = typeof MacPasskeySigningConfigurationError.Type;
export const isMacPasskeySigningConfigurationError = Schema.is(MacPasskeySigningConfigurationError);
Expand Down Expand Up @@ -1267,14 +1257,13 @@ export function resolveMacPasskeySigningConfiguration(
}

const configuredRpDomains = env.T3CODE_CLERK_PASSKEY_RP_DOMAINS?.trim();
const publishableKey = env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim();
let rpDomains: readonly string[];
if (configuredRpDomains) {
rpDomains = configuredRpDomains.split(",").map(normalizePasskeyRpDomain);
} else if (!publishableKey) {
rpDomains = [];
} else {
const publishableKey = env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim();
if (!publishableKey) {
throw new MissingMacPasskeyDomainConfigurationError();
}
let hostname: string;
try {
hostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey);
Expand All @@ -1284,15 +1273,10 @@ export function resolveMacPasskeySigningConfiguration(
rpDomains = [normalizePasskeyRpDomain(hostname)];
}

const uniqueRpDomains = [...new Set(rpDomains)];
if (uniqueRpDomains.length === 0) {
throw new MissingMacPasskeyRpDomainError();
}

return {
appId: DESKTOP_APP_ID,
teamId,
rpDomains: uniqueRpDomains,
rpDomains: [...new Set(rpDomains)],
provisioningProfilePath,
};
}
Expand All @@ -1308,14 +1292,18 @@ function escapeXml(value: string): string {

/** macOS refuses restricted entitlements absent from the provisioning profile. */
export interface MacWebAuthnEntitlements {
readonly associatedDomains: readonly string[];
readonly touchIdKeychainAccessGroup: string | undefined;
readonly browserPasskeys: boolean;
}

const ASSOCIATED_DOMAINS_ENTITLEMENT = "com.apple.developer.associated-domains";
const BROWSER_PASSKEYS_ENTITLEMENT = "com.apple.developer.web-browser.public-key-credential";

const ProvisioningProfilePlist = Schema.Struct({
Entitlements: Schema.Struct({
// Profiles grant every domain as "*"; the app picks its own.
[ASSOCIATED_DOMAINS_ENTITLEMENT]: Schema.optional(Schema.Unknown),
"keychain-access-groups": Schema.optional(Schema.Array(Schema.String)),
[BROWSER_PASSKEYS_ENTITLEMENT]: Schema.optional(Schema.Boolean),
}),
Expand All @@ -1336,28 +1324,38 @@ const readProfileEntitlements = (provisioningProfile: string) => {

export function resolveMacWebAuthnEntitlements(
provisioningProfile: string,
configuration: Pick<MacPasskeySigningConfiguration, "appId" | "teamId">,
configuration: Pick<MacPasskeySigningConfiguration, "appId" | "teamId" | "rpDomains">,
): MacWebAuthnEntitlements {
const entitlements = readProfileEntitlements(provisioningProfile);
const associatedDomainsGranted = entitlements?.[ASSOCIATED_DOMAINS_ENTITLEMENT] !== undefined;
if (associatedDomainsGranted && configuration.rpDomains.length === 0) {
throw new MissingMacPasskeyDomainConfigurationError();
}
const keychainAccessGroup = `${configuration.teamId}.${configuration.appId}.webauthn`;
const keychainGroupAuthorized = (entitlements?.["keychain-access-groups"] ?? []).some((group) =>
group.endsWith("*")
? keychainAccessGroup.startsWith(group.slice(0, -1))
: group === keychainAccessGroup,
);
return {
associatedDomains: associatedDomainsGranted ? configuration.rpDomains : [],
touchIdKeychainAccessGroup: keychainGroupAuthorized ? keychainAccessGroup : undefined,
browserPasskeys: entitlements?.[BROWSER_PASSKEYS_ENTITLEMENT] === true,
};
}

export function renderMacPasskeyEntitlements(
configuration: MacPasskeySigningConfiguration,
configuration: Pick<MacPasskeySigningConfiguration, "appId" | "teamId">,
webAuthn: MacWebAuthnEntitlements,
): string {
const associatedDomains = configuration.rpDomains
.map((domain) => ` <string>webcredentials:${escapeXml(domain)}</string>`)
.join("\n");
const associatedDomains =
webAuthn.associatedDomains.length > 0
? `
<key>${ASSOCIATED_DOMAINS_ENTITLEMENT}</key>
<array>
${webAuthn.associatedDomains.map((domain) => ` <string>webcredentials:${escapeXml(domain)}</string>`).join("\n")}
</array>`
: "";
const keychainAccessGroups = webAuthn.touchIdKeychainAccessGroup
? `
<key>keychain-access-groups</key>
Expand All @@ -1378,11 +1376,7 @@ export function renderMacPasskeyEntitlements(
<key>com.apple.application-identifier</key>
<string>${escapeXml(`${configuration.teamId}.${configuration.appId}`)}</string>
<key>com.apple.developer.team-identifier</key>
<string>${escapeXml(configuration.teamId)}</string>
<key>com.apple.developer.associated-domains</key>
<array>
${associatedDomains}
</array>${keychainAccessGroups}${browserPasskeys}
<string>${escapeXml(configuration.teamId)}</string>${associatedDomains}${keychainAccessGroups}${browserPasskeys}
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
Expand Down Expand Up @@ -3831,12 +3825,13 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f
provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
});
}
macWebAuthn = resolveMacWebAuthnEntitlements(
yield* fs.readFileString(macPasskeySigning.provisioningProfilePath),
macPasskeySigning,
);
const provisioningProfile = yield* fs.readFileString(macPasskeySigning.provisioningProfilePath);
macWebAuthn = yield* Effect.try({
try: () => resolveMacWebAuthnEntitlements(provisioningProfile, macPasskeySigning),
catch: MacPasskeySigningConfigurationResolutionError.fromCause,
});
yield* Effect.log(
`[desktop-artifact] In-app browser passkeys: Touch ID ${macWebAuthn.touchIdKeychainAccessGroup ? "enabled" : "disabled"}, browser passkeys ${macWebAuthn.browserPasskeys ? "enabled" : "disabled"}.`,
`[desktop-artifact] Passkey entitlements: associated domains ${macWebAuthn.associatedDomains.length > 0 ? macWebAuthn.associatedDomains.join(", ") : "none"}, Touch ID ${macWebAuthn.touchIdKeychainAccessGroup ? "enabled" : "disabled"}, browser passkeys ${macWebAuthn.browserPasskeys ? "enabled" : "disabled"}.`,
);
yield* fs.writeFileString(
macEntitlementsPath,
Expand Down
Loading