Skip to content

Commit 8ff5ec4

Browse files
OAuth client validation improvements (#4289)
* OAuth client validation improvements * Remove `isLocalHostname` export * tidy
1 parent 1e702ea commit 8ff5ec4

File tree

14 files changed

+161
-119
lines changed

14 files changed

+161
-119
lines changed

.changeset/afraid-rings-tan.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@atproto/oauth-client-expo": patch
3+
---
4+
5+
The package now re-exports everything from `@atproto/oauth-client`, avoiding the need to explicitly depend on it.

.changeset/honest-comics-search.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@atproto/oauth-types": patch
3+
---
4+
5+
Enforce stronger validation of `privateUseUriSchema`

.changeset/lucky-lies-float.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@atproto/oauth-types": minor
3+
---
4+
5+
Remove exported `oauthHttpsRedirectURISchema` and `oauthPrivateUseRedirectURISchema` in favor of `oauthRedirectUriSchema` and `oauthLoopbackClientRedirectUriSchema`, which provide semantically meaningful groupings of redirect URIs based on OAuth client types.
6+
7+
`oauthHttpsRedirectURISchema` can still be accessed using `httpsUriSchema`.
8+
`oauthPrivateUseRedirectURISchema` can still be accessed using `privateUseUriSchema`.
9+

.changeset/soft-ghosts-try.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@atproto-labs/fetch-node": minor
3+
---
4+
5+
Remove `isLocalHostname` export

.changeset/tiny-zebras-cough.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@atproto/oauth-types": minor
3+
---
4+
5+
Remove unused `isLoopbackUrl` utility

packages/internal/fetch-node/src/unicast.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export function unicastLookup(
229229
* @returns whether the hostname is a name typically used for on locale area networks.
230230
* @note **DO NOT** use for security reasons. Only as heuristic.
231231
*/
232-
export function isLocalHostname(hostname: string): boolean {
232+
function isLocalHostname(hostname: string): boolean {
233233
const parts = hostname.split('.')
234234
if (parts.length < 2) return true
235235

packages/oauth/oauth-client-expo/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import './polyfill'
22

3+
export * from '@atproto/oauth-client'
4+
35
export type { ExpoOAuthClientInterface } from './expo-oauth-client-interface'
46
export type { ExpoOAuthClientOptions } from './expo-oauth-client-options'
57

packages/oauth/oauth-provider/src/client/client-manager.ts

Lines changed: 22 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
OAuthClientIdLoopback,
66
OAuthClientMetadata,
77
OAuthClientMetadataInput,
8-
isLoopbackHost,
8+
isLocalHostname,
99
isOAuthClientIdDiscoverable,
1010
isOAuthClientIdLoopback,
1111
oauthClientMetadataSchema,
@@ -17,7 +17,6 @@ import {
1717
fetchJsonZodProcessor,
1818
fetchOkProcessor,
1919
} from '@atproto-labs/fetch'
20-
import { isLocalHostname } from '@atproto-labs/fetch-node'
2120
import { pipe } from '@atproto-labs/pipe'
2221
import {
2322
CachedGetter,
@@ -232,6 +231,10 @@ export class ClientManager {
232231
clientId: ClientId,
233232
metadata: OAuthClientMetadata,
234233
): OAuthClientMetadata {
234+
// @TODO This method should only check for rules that are specific to this
235+
// implementation or the ATPROTO specification. All generic validation rules
236+
// should be moved to the @atproto/oauth-types package.
237+
235238
if (metadata.jwks && metadata.jwks_uri) {
236239
throw new InvalidClientMetadataError(
237240
'jwks_uri and jwks are mutually exclusive',
@@ -604,56 +607,12 @@ export class ClientManager {
604607
}
605608

606609
case isPrivateUseUriScheme(url): {
607-
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.1
608-
//
609-
// > When choosing a URI scheme to associate with the app, apps MUST
610-
// > use a URI scheme based on a domain name under their control,
611-
// > expressed in reverse order, as recommended by Section 3.8 of
612-
// > [RFC7595] for private-use URI schemes.
613-
614610
if (metadata.application_type !== 'native') {
615611
throw new InvalidRedirectUriError(
616612
`Private-Use URI Scheme redirect URI are only allowed for native apps`,
617613
)
618614
}
619615

620-
// https://datatracker.ietf.org/doc/html/rfc8252#section-8.4
621-
//
622-
// > In addition to the collision-resistant properties, requiring a
623-
// > URI scheme based on a domain name that is under the control of
624-
// > the app can help to prove ownership in the event of a dispute
625-
// > where two apps claim the same private-use URI scheme (where one
626-
// > app is acting maliciously).
627-
//
628-
// We can't check for ownership here (as there is no concept of
629-
// proven ownership in the generic client validation), but we can
630-
// check that the domain is a valid domain name.
631-
632-
const urlDomain = reverseDomain(url.protocol.slice(0, -1))
633-
634-
if (isLocalHostname(urlDomain)) {
635-
throw new InvalidRedirectUriError(
636-
`Private-use URI Scheme redirect URI must not be a local hostname`,
637-
)
638-
}
639-
640-
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.1
641-
//
642-
// > Following the requirements of Section 3.2 of [RFC3986], as there
643-
// > is no naming authority for private-use URI scheme redirects, only
644-
// > a single slash ("/") appears after the scheme component.
645-
if (
646-
url.href.startsWith(`${url.protocol}//`) ||
647-
url.username ||
648-
url.password ||
649-
url.hostname ||
650-
url.port
651-
) {
652-
throw new InvalidRedirectUriError(
653-
`Private-Use URI Scheme must be in the form ${url.protocol}/<path>`,
654-
)
655-
}
656-
657616
break
658617
}
659618

@@ -700,22 +659,6 @@ export class ClientManager {
700659
)
701660
}
702661

703-
for (const redirectUri of metadata.redirect_uris) {
704-
const url = parseRedirectUri(redirectUri)
705-
706-
if (url.protocol !== 'http:') {
707-
throw new InvalidRedirectUriError(
708-
`Loopback clients must use HTTP redirect URIs`,
709-
)
710-
}
711-
712-
if (!isLoopbackHost(url.hostname)) {
713-
throw new InvalidRedirectUriError(
714-
`Loopback clients must use loopback redirect URIs`,
715-
)
716-
}
717-
}
718-
719662
return metadata
720663
}
721664

@@ -762,9 +705,19 @@ export class ClientManager {
762705
}
763706

764707
for (const redirectUri of metadata.redirect_uris) {
708+
// @NOTE at this point, all redirect URIs have already been validated by
709+
// oauthRedirectUriSchema
710+
765711
const url = parseRedirectUri(redirectUri)
766712

767713
if (isPrivateUseUriScheme(url)) {
714+
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.1
715+
//
716+
// > When choosing a URI scheme to associate with the app, apps MUST use
717+
// > a URI scheme based on a domain name under their control, expressed
718+
// > in reverse order, as recommended by Section 3.8 of [RFC7595] for
719+
// > private-use URI schemes.
720+
768721
// https://datatracker.ietf.org/doc/html/rfc8252#section-8.4
769722
//
770723
// > In addition to the collision-resistant properties, requiring a
@@ -773,11 +726,14 @@ export class ClientManager {
773726
// > where two apps claim the same private-use URI scheme (where one
774727
// > app is acting maliciously).
775728

776-
// https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html
729+
// https://atproto.com/specs/oauth
777730
//
778-
// Fully qualified domain name (FQDN) of the client_id, in reverse
779-
// order. This could be relaxed to allow same apex domain names, or
780-
// parent domains, but for now we require an exact match.
731+
// > Any custom scheme must match the client_id hostname in
732+
// > reverse-domain order. The URI scheme must be followed by a single
733+
// > colon (:) then a single forward slash (/) and then a URI path
734+
// > component. For example, an app with client_id
735+
// > https://app.example.com/client-metadata.json could have a
736+
// > redirect_uri of com.example.app:/callback.
781737
const protocol = `${reverseDomain(clientIdUrl.hostname)}:`
782738
if (url.protocol !== protocol) {
783739
throw new InvalidRedirectUriError(

packages/oauth/oauth-provider/src/client/client-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {
22
OAuthClientIdDiscoverable,
3+
isLocalHostname,
34
parseOAuthDiscoverableClientId,
45
} from '@atproto/oauth-types'
5-
import { isLocalHostname } from '@atproto-labs/fetch-node'
66
import { InvalidClientIdError } from '../errors/invalid-client-id-error.js'
77
import { InvalidRedirectUriError } from '../errors/invalid-redirect-uri-error.js'
88

packages/oauth/oauth-types/src/atproto-loopback-client-id.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from './oauth-client-id-loopback.js'
1313
import {
1414
OAuthLoopbackRedirectURI,
15-
oauthLoopbackRedirectURISchema,
15+
oauthLoopbackClientRedirectUriSchema,
1616
} from './oauth-redirect-uri.js'
1717
import { arrayEquivalent, asArray } from './util.js'
1818

@@ -41,7 +41,10 @@ export function buildAtprotoLoopbackClientId(
4141
throw new TypeError(`Unexpected empty "redirect_uris" config`)
4242
}
4343
for (const uri of redirectUris) {
44-
params.append('redirect_uri', oauthLoopbackRedirectURISchema.parse(uri))
44+
params.append(
45+
'redirect_uri',
46+
oauthLoopbackClientRedirectUriSchema.parse(uri),
47+
)
4548
}
4649
}
4750

0 commit comments

Comments
 (0)