Skip to content

Commit 6fe6724

Browse files
authored
feat(jans-fido2): export assetlinks.json and apple-app-site-association from the RP config (slice 1 of 2) (#15514)
* feat(jans-fido2): export assetlinks.json and apple-app-site-association from the RP config Signed-off-by: imran <imranishaq7071@gmail.com> * docs(jans-fido2): explain how to verify the deployed association files Signed-off-by: imran <imranishaq7071@gmail.com> * docs(jans-fido2): tell admins to merge the exported association entries and use the App ID prefix Signed-off-by: imran <imranishaq7071@gmail.com> --------- Signed-off-by: imran <imranishaq7071@gmail.com>
1 parent 324c6d7 commit 6fe6724

6 files changed

Lines changed: 276 additions & 2 deletions

File tree

‎docs/janssen-server/fido/fido2-server-properties-config.md‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ An entry under `rp` may also list the Android and iOS apps that share its RP ID,
204204
| `androidApps[].packageName` | Android application package name |
205205
| `androidApps[].sha256CertFingerprints` | SHA-256 fingerprints of the app signing certificate, colon-separated hex |
206206
| `androidApps[].distribution` | `play-store` or `self-signed`: which signing certificate the fingerprints belong to |
207-
| `iosApps[].teamId` | Apple Developer Team ID |
207+
| `iosApps[].teamId` | Apple Developer Team ID, or the app's App ID prefix when that differs (older apps) |
208208
| `iosApps[].bundleId` | Application bundle identifier |
209209

210210
This is the single place an RP's native apps are recorded. It is what the `assetlinks.json` and
@@ -220,6 +220,37 @@ these fields existed, and nothing needs changing on upgrade.
220220
To check that the `assetlinks.json` and `apple-app-site-association` files an RP has deployed match these
221221
entries, see [Native App Readiness](native-app-readiness.md).
222222

223+
#### Exporting the association files
224+
225+
The server generates both files from these fields, for you to deploy on the RP-ID host. They are the same
226+
files the readiness check compares a deployed copy against.
227+
228+
| Request | File |
229+
| --- | --- |
230+
| `GET /jans-fido2/restv1/rp/{rpConfigId}/association/assetlinks.json` | Android `assetlinks.json`, one statement per `androidApps` entry |
231+
| `GET /jans-fido2/restv1/rp/{rpConfigId}/association/apple-app-site-association` | Apple file, one `TeamID.BundleID` per complete `iosApps` entry |
232+
233+
`{rpConfigId}` is the RP `id` as configured, URL-encoded. The answer is a JSON download, `404` when the RP is
234+
not configured or has no app of that platform. Nothing is served from Jans: both Apple and Android fetch
235+
`https://<rp-id>/.well-known/...` without following redirects, so deploy each file on the RP-ID host itself.
236+
Apple's file is named `apple-app-site-association`, with no `.json` extension.
237+
238+
**Merge the exported entries into any file that is already there; do not replace it.** The exports describe
239+
only the passkey (`webcredentials` and `get_login_creds`) entries. A host that already serves these files may
240+
also use them for universal links or Android App Links, such as `applinks` in the Apple file or other
241+
statements in `assetlinks.json`, and overwriting the file removes those. Add the exported statements to the
242+
Android array, and the exported app IDs to `webcredentials.apps`, and leave everything else as it is.
243+
244+
**Check that `teamId` is the app's App ID prefix.** Apple expects `<App ID prefix>.<bundle ID>` in
245+
`webcredentials.apps`. For most apps the prefix is the Team ID, but for older apps it can be a different value.
246+
Use the prefix shown for the app's App ID under Identifiers in your Apple Developer account (it is also the
247+
part before the bundle ID in the app's `application-identifier` entitlement), and record that value as `teamId`.
248+
249+
After deploying, run the native app readiness check for the same RP
250+
(`POST /jans-fido2/restv1/rp/{rpConfigId}/readiness`, which needs `nativeReadinessEnabled` and
251+
`nativeReadinessAllowExternalFetch`) to confirm the deployed files match the configured apps. A mismatched
252+
fingerprint or Team ID is the most common deployment failure, and the check reports it.
253+
223254
### Native app readiness
224255

225256
The `nativeReadiness*` properties control a check that compares the `assetlinks.json` and

‎jans-fido2/server/src/main/java/io/jans/fido2/service/app/ResteasyInitializer.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import io.jans.fido2.ws.rs.controller.ConfigurationController;
1919
import io.jans.fido2.ws.rs.controller.Fido2MetricsController;
2020
import io.jans.fido2.ws.rs.controller.Fido2TrustController;
21+
import io.jans.fido2.ws.rs.controller.AssociationFileController;
2122
import io.jans.fido2.ws.rs.controller.NativeReadinessController;
2223

2324
/**
@@ -39,6 +40,7 @@ public Set<Class<?>> getClasses() {
3940
classes.add(Fido2MetricsController.class);
4041
classes.add(Fido2TrustController.class);
4142
classes.add(NativeReadinessController.class);
43+
classes.add(AssociationFileController.class);
4244

4345
return classes;
4446
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
3+
*
4+
* Copyright (c) 2026, Janssen Project
5+
*/
6+
7+
package io.jans.fido2.service.readiness;
8+
9+
import com.fasterxml.jackson.databind.JsonNode;
10+
11+
import io.jans.fido2.model.conf.AppConfiguration;
12+
import io.jans.fido2.model.conf.Fido2Configuration;
13+
import io.jans.fido2.model.conf.RequestedParty;
14+
import io.jans.fido2.service.readiness.NativeReadinessService.NotFound;
15+
import jakarta.enterprise.context.ApplicationScoped;
16+
import jakarta.inject.Inject;
17+
18+
/**
19+
* Produces the {@code assetlinks.json} and {@code apple-app-site-association} files of a configured relying
20+
* party, for the admin to deploy on the RP-ID host. Both come from {@link ExpectedAssociationFileBuilder},
21+
* the same definition the readiness checker compares the deployed files against. Nothing is fetched or served
22+
* from here.
23+
*/
24+
@ApplicationScoped
25+
public class AssociationFileExporter {
26+
27+
@Inject
28+
private AppConfiguration appConfiguration;
29+
30+
/**
31+
* @throws NotFound when no relying party has that id, or it has no Android app to describe
32+
*/
33+
public JsonNode assetLinks(String rpConfigId) {
34+
RequestedParty party = party(rpConfigId);
35+
if (ExpectedAssociationFileBuilder.androidApps(party).isEmpty()) {
36+
throw new NotFound();
37+
}
38+
return ExpectedAssociationFileBuilder.assetLinks(party);
39+
}
40+
41+
/**
42+
* @throws NotFound when no relying party has that id, or it has no complete iOS app to describe
43+
*/
44+
public JsonNode appleAppSiteAssociation(String rpConfigId) {
45+
RequestedParty party = party(rpConfigId);
46+
JsonNode file = ExpectedAssociationFileBuilder.appleAppSiteAssociation(party);
47+
if (file.path("webcredentials").path("apps").isEmpty()) {
48+
throw new NotFound();
49+
}
50+
return file;
51+
}
52+
53+
private RequestedParty party(String rpConfigId) {
54+
Fido2Configuration config = appConfiguration.getFido2Configuration();
55+
RequestedParty party = config == null ? null : NativeReadinessService.find(config, rpConfigId);
56+
if (party == null) {
57+
throw new NotFound();
58+
}
59+
return party;
60+
}
61+
}

‎jans-fido2/server/src/main/java/io/jans/fido2/service/readiness/NativeReadinessService.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ private static int fileCount(RequestedParty party) {
247247
+ (ExpectedAssociationFileBuilder.iosApps(party).isEmpty() ? 0 : 1);
248248
}
249249

250-
private static RequestedParty find(Fido2Configuration config, String id) {
250+
static RequestedParty find(Fido2Configuration config, String id) {
251251
if (id == null || config.getRequestedParties() == null) {
252252
return null;
253253
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
3+
*
4+
* Copyright (c) 2026, Janssen Project
5+
*/
6+
7+
package io.jans.fido2.ws.rs.controller;
8+
9+
import com.fasterxml.jackson.core.JsonProcessingException;
10+
import com.fasterxml.jackson.databind.JsonNode;
11+
12+
import io.jans.fido2.service.DataMapperService;
13+
import io.jans.fido2.service.readiness.AssociationFileExporter;
14+
import io.jans.fido2.service.readiness.NativeReadinessService.NotFound;
15+
import jakarta.enterprise.context.ApplicationScoped;
16+
import jakarta.inject.Inject;
17+
import jakarta.ws.rs.GET;
18+
import jakarta.ws.rs.Path;
19+
import jakarta.ws.rs.PathParam;
20+
import jakarta.ws.rs.Produces;
21+
import jakarta.ws.rs.core.MediaType;
22+
import jakarta.ws.rs.core.Response;
23+
24+
/**
25+
* Exports the native app association files of one configured relying party, to be deployed by the admin on
26+
* the RP-ID host's {@code /.well-known/} path: {@code GET /rp/{rpConfigId}/association/assetlinks.json} and
27+
* {@code GET /rp/{rpConfigId}/association/apple-app-site-association}.
28+
* <p>
29+
* {@code rpConfigId} is the RP id exactly as configured, as a URL-encoded path segment. The answer is 404 for
30+
* an id that is not configured, and also when the RP has no app of that platform, since there is nothing to
31+
* deploy. The body is the file itself, sent as a download; the Apple file's name has no extension, as Apple
32+
* requires. These endpoints only read configuration and make no outbound request.
33+
* <p>
34+
* SECURITY NOTE: as with {@link NativeReadinessController}, authentication and authorization are expected to
35+
* be enforced at the infrastructure level or via the Config API fido2 plugin, not here.
36+
*
37+
* @author Janssen Project
38+
*/
39+
@ApplicationScoped
40+
@Path("/rp/{rpConfigId}/association")
41+
public class AssociationFileController {
42+
43+
@Inject
44+
private AssociationFileExporter exporter;
45+
46+
@Inject
47+
private DataMapperService dataMapperService;
48+
49+
@GET
50+
@Path("/assetlinks.json")
51+
@Produces(MediaType.APPLICATION_JSON)
52+
public Response assetLinks(@PathParam("rpConfigId") String rpConfigId)
53+
throws JsonProcessingException {
54+
try {
55+
return download(exporter.assetLinks(rpConfigId), "assetlinks.json");
56+
} catch (NotFound e) {
57+
return Response.status(Response.Status.NOT_FOUND).build();
58+
}
59+
}
60+
61+
@GET
62+
@Path("/apple-app-site-association")
63+
@Produces(MediaType.APPLICATION_JSON)
64+
public Response appleAppSiteAssociation(@PathParam("rpConfigId") String rpConfigId)
65+
throws JsonProcessingException {
66+
try {
67+
return download(exporter.appleAppSiteAssociation(rpConfigId), "apple-app-site-association");
68+
} catch (NotFound e) {
69+
return Response.status(Response.Status.NOT_FOUND).build();
70+
}
71+
}
72+
73+
private Response download(JsonNode file, String fileName) throws JsonProcessingException {
74+
return Response.ok(dataMapperService.writeValueAsString(file))
75+
.header("Content-Disposition", "attachment; filename=\"" + fileName + "\"").build();
76+
}
77+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
3+
*
4+
* Copyright (c) 2026, Janssen Project
5+
*/
6+
7+
package io.jans.fido2.service.readiness;
8+
9+
import static org.junit.jupiter.api.Assertions.assertEquals;
10+
import static org.junit.jupiter.api.Assertions.assertThrows;
11+
import static org.mockito.Mockito.when;
12+
13+
import java.util.List;
14+
15+
import org.junit.jupiter.api.BeforeEach;
16+
import org.junit.jupiter.api.Test;
17+
import org.junit.jupiter.api.extension.ExtendWith;
18+
import org.mockito.InjectMocks;
19+
import org.mockito.Mock;
20+
import org.mockito.junit.jupiter.MockitoExtension;
21+
22+
import com.fasterxml.jackson.databind.JsonNode;
23+
24+
import io.jans.fido2.model.conf.AndroidApp;
25+
import io.jans.fido2.model.conf.AppConfiguration;
26+
import io.jans.fido2.model.conf.Fido2Configuration;
27+
import io.jans.fido2.model.conf.IosApp;
28+
import io.jans.fido2.model.conf.RequestedParty;
29+
import io.jans.fido2.service.readiness.NativeReadinessService.NotFound;
30+
31+
@ExtendWith(MockitoExtension.class)
32+
class AssociationFileExporterTest {
33+
34+
private static final String FP = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99";
35+
36+
@Mock
37+
private AppConfiguration appConfiguration;
38+
39+
@InjectMocks
40+
private AssociationFileExporter exporter;
41+
42+
private RequestedParty party;
43+
44+
@BeforeEach
45+
void setUp() {
46+
party = new RequestedParty();
47+
party.setId("example.com");
48+
Fido2Configuration config = new Fido2Configuration();
49+
config.setRequestedParties(List.of(party));
50+
when(appConfiguration.getFido2Configuration()).thenReturn(config);
51+
}
52+
53+
private static AndroidApp android(String pkg, String... fingerprints) {
54+
AndroidApp app = new AndroidApp();
55+
app.setPackageName(pkg);
56+
app.setSha256CertFingerprints(List.of(fingerprints));
57+
return app;
58+
}
59+
60+
private static IosApp ios(String team, String bundle) {
61+
IosApp app = new IosApp();
62+
app.setTeamId(team);
63+
app.setBundleId(bundle);
64+
return app;
65+
}
66+
67+
@Test
68+
void assetLinks_isTheBuilderOutput_forTheConfiguredAndroidApps() {
69+
party.setAndroidApps(List.of(android("com.example.app", FP.toLowerCase())));
70+
71+
JsonNode file = exporter.assetLinks("example.com");
72+
73+
assertEquals(ExpectedAssociationFileBuilder.assetLinks(party), file);
74+
assertEquals("com.example.app", file.get(0).path("target").path("package_name").asText());
75+
assertEquals(FP, file.get(0).path("target").path("sha256_cert_fingerprints").get(0).asText());
76+
}
77+
78+
@Test
79+
void appleAppSiteAssociation_listsTeamIdDotBundleId() {
80+
party.setIosApps(List.of(ios("TEAM123456", "com.example.app")));
81+
82+
JsonNode file = exporter.appleAppSiteAssociation("example.com");
83+
84+
assertEquals("TEAM123456.com.example.app", file.path("webcredentials").path("apps").get(0).asText());
85+
}
86+
87+
@Test
88+
void unknownRp_isNotFound() {
89+
assertThrows(NotFound.class, () -> exporter.assetLinks("other.example"));
90+
assertThrows(NotFound.class, () -> exporter.appleAppSiteAssociation(null));
91+
}
92+
93+
@Test
94+
void rpWithoutAppsOfThePlatform_hasNothingToExport() {
95+
party.setAndroidApps(List.of(android("com.example.app", FP)));
96+
party.setIosApps(List.of(ios("TEAM123456", " ")));
97+
98+
assertThrows(NotFound.class, () -> exporter.appleAppSiteAssociation("example.com"));
99+
100+
party.setAndroidApps(null);
101+
assertThrows(NotFound.class, () -> exporter.assetLinks("example.com"));
102+
}
103+
}

0 commit comments

Comments
 (0)