fix: register API resource in Logto for JWT access tokens
Logto returns opaque access tokens when no resource is specified. Added API resource creation to bootstrap, included resource indicator in /api/config, and SPA now passes resource parameter in auth request. Also fixed issuer-uri to match Logto's public endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ POSTGRES_DB=cameleer_saas
|
|||||||
# Logto Identity Provider
|
# Logto Identity Provider
|
||||||
LOGTO_ENDPOINT=http://logto:3001
|
LOGTO_ENDPOINT=http://logto:3001
|
||||||
LOGTO_PUBLIC_ENDPOINT=http://localhost:3001
|
LOGTO_PUBLIC_ENDPOINT=http://localhost:3001
|
||||||
LOGTO_ISSUER_URI=http://logto:3001/oidc
|
LOGTO_ISSUER_URI=http://localhost:3001/oidc
|
||||||
LOGTO_JWK_SET_URI=http://logto:3001/oidc/jwks
|
LOGTO_JWK_SET_URI=http://logto:3001/oidc/jwks
|
||||||
LOGTO_DB_PASSWORD=change_me_in_production
|
LOGTO_DB_PASSWORD=change_me_in_production
|
||||||
LOGTO_M2M_CLIENT_ID=
|
LOGTO_M2M_CLIENT_ID=
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ PG_DB="logto"
|
|||||||
|
|
||||||
SPA_APP_NAME="Cameleer SaaS"
|
SPA_APP_NAME="Cameleer SaaS"
|
||||||
M2M_APP_NAME="Cameleer SaaS Backend"
|
M2M_APP_NAME="Cameleer SaaS Backend"
|
||||||
|
API_RESOURCE_INDICATOR="https://api.cameleer.local"
|
||||||
|
API_RESOURCE_NAME="Cameleer SaaS API"
|
||||||
DEFAULT_USERNAME="camel"
|
DEFAULT_USERNAME="camel"
|
||||||
DEFAULT_PASSWORD="camel"
|
DEFAULT_PASSWORD="camel"
|
||||||
|
|
||||||
@@ -103,6 +105,23 @@ else
|
|||||||
log "Created SPA app: $SPA_ID"
|
log "Created SPA app: $SPA_ID"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Find or create API resource ---
|
||||||
|
log "Checking for existing API resource..."
|
||||||
|
EXISTING_RESOURCES=$(api_get "/api/resources")
|
||||||
|
API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id")
|
||||||
|
|
||||||
|
if [ -n "$API_RESOURCE_ID" ]; then
|
||||||
|
log "API resource already exists: $API_RESOURCE_ID"
|
||||||
|
else
|
||||||
|
log "Creating API resource..."
|
||||||
|
RESOURCE_RESPONSE=$(api_post "/api/resources" "{
|
||||||
|
\"name\": \"$API_RESOURCE_NAME\",
|
||||||
|
\"indicator\": \"$API_RESOURCE_INDICATOR\"
|
||||||
|
}")
|
||||||
|
API_RESOURCE_ID=$(echo "$RESOURCE_RESPONSE" | jq -r '.id')
|
||||||
|
log "Created API resource: $API_RESOURCE_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Find or create M2M app ---
|
# --- Find or create M2M app ---
|
||||||
log "Checking for existing M2M app..."
|
log "Checking for existing M2M app..."
|
||||||
M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id")
|
M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id")
|
||||||
@@ -194,6 +213,7 @@ cat > "$BOOTSTRAP_FILE" <<EOF
|
|||||||
"spaClientId": "$SPA_ID",
|
"spaClientId": "$SPA_ID",
|
||||||
"m2mClientId": "$M2M_ID",
|
"m2mClientId": "$M2M_ID",
|
||||||
"m2mClientSecret": "$M2M_SECRET",
|
"m2mClientSecret": "$M2M_SECRET",
|
||||||
|
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
|
||||||
"defaultUsername": "$DEFAULT_USERNAME"
|
"defaultUsername": "$DEFAULT_USERNAME"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -27,13 +27,17 @@ public class PublicConfigController {
|
|||||||
|
|
||||||
@GetMapping("/api/config")
|
@GetMapping("/api/config")
|
||||||
public Map<String, String> config() {
|
public Map<String, String> config() {
|
||||||
String clientId = spaClientId;
|
JsonNode bootstrap = readBootstrapFile();
|
||||||
|
|
||||||
// Fall back to bootstrap file if env var not set
|
String clientId = spaClientId;
|
||||||
if (clientId == null || clientId.isEmpty()) {
|
if (clientId == null || clientId.isEmpty()) {
|
||||||
clientId = readBootstrapClientId();
|
clientId = bootstrap != null && bootstrap.has("spaClientId")
|
||||||
|
? bootstrap.get("spaClientId").asText() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String apiResource = bootstrap != null && bootstrap.has("apiResourceIndicator")
|
||||||
|
? bootstrap.get("apiResourceIndicator").asText() : "";
|
||||||
|
|
||||||
// Use public endpoint for browser redirects (not Docker-internal URL)
|
// Use public endpoint for browser redirects (not Docker-internal URL)
|
||||||
String endpoint = logtoPublicEndpoint;
|
String endpoint = logtoPublicEndpoint;
|
||||||
if (endpoint == null || endpoint.isEmpty()) {
|
if (endpoint == null || endpoint.isEmpty()) {
|
||||||
@@ -42,20 +46,20 @@ public class PublicConfigController {
|
|||||||
|
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"logtoEndpoint", endpoint,
|
"logtoEndpoint", endpoint,
|
||||||
"logtoClientId", clientId != null ? clientId : ""
|
"logtoClientId", clientId != null ? clientId : "",
|
||||||
|
"logtoResource", apiResource
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String readBootstrapClientId() {
|
private JsonNode readBootstrapFile() {
|
||||||
try {
|
try {
|
||||||
File file = new File(BOOTSTRAP_FILE);
|
File file = new File(BOOTSTRAP_FILE);
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
JsonNode node = objectMapper.readTree(file);
|
return objectMapper.readTree(file);
|
||||||
return node.has("spaClientId") ? node.get("spaClientId").asText() : "";
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to read bootstrap config: {}", e.getMessage());
|
log.warn("Failed to read bootstrap config: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
return "";
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { fetchConfig } from '../config';
|
|||||||
import { generatePkce, storeCodeVerifier } from './pkce';
|
import { generatePkce, storeCodeVerifier } from './pkce';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const [config, setConfig] = useState<{ logtoEndpoint: string; logtoClientId: string } | null>(null);
|
const [config, setConfig] = useState<{ logtoEndpoint: string; logtoClientId: string; logtoResource: string } | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,6 +35,9 @@ export function LoginPage() {
|
|||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
});
|
});
|
||||||
|
if (config.logtoResource) {
|
||||||
|
params.set('resource', config.logtoResource);
|
||||||
|
}
|
||||||
window.location.href = `${config.logtoEndpoint}/oidc/auth?${params}`;
|
window.location.href = `${config.logtoEndpoint}/oidc/auth?${params}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
logtoEndpoint: string;
|
logtoEndpoint: string;
|
||||||
logtoClientId: string;
|
logtoClientId: string;
|
||||||
|
logtoResource: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cached: AppConfig | null = null;
|
let cached: AppConfig | null = null;
|
||||||
@@ -22,6 +23,7 @@ export async function fetchConfig(): Promise<AppConfig> {
|
|||||||
cached = {
|
cached = {
|
||||||
logtoEndpoint: import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001',
|
logtoEndpoint: import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001',
|
||||||
logtoClientId: import.meta.env.VITE_LOGTO_CLIENT_ID || '',
|
logtoClientId: import.meta.env.VITE_LOGTO_CLIENT_ID || '',
|
||||||
|
logtoResource: import.meta.env.VITE_LOGTO_RESOURCE || '',
|
||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user